Compare commits

...

37 Commits

Author SHA1 Message Date
Jon Staab ca4e5ae5ee Add shadow to thread items etc, bump welshman, update changelog, update version 2025-11-04 17:14:33 -08:00
Jon Staab b673658c0c Handle escape in chat 2025-11-04 16:59:17 -08:00
Jon Staab 5c5c130700 Add landlubber link if user is admin 2025-11-04 16:55:26 -08:00
Jon Staab 2d89ca6c0e Support invite links on discover page 2025-11-04 16:39:34 -08:00
Jon Staab 806a7c2609 Persist alert kinds again 2025-11-04 16:25:21 -08:00
Jon Staab 501ce8067d Detect nip29 properly before choosing smart path, more robust auth error checking 2025-11-04 16:14:32 -08:00
Jon Staab 6429f82829 Improve claim/access detection 2025-11-04 15:36:20 -08:00
Jon Staab fe626218ea Ignore aborted signatures when checking auth 2025-11-04 09:34:07 -08:00
Jon Staab b62b1bc063 Don't source local .env file on build 2025-11-04 09:18:26 -08:00
Jon Staab d980f36246 Use request instead of load to avoid timeouts 2025-11-04 09:05:17 -08:00
Jon Staab b469addd29 Remove withGetter 2025-11-03 14:52:12 -08:00
Jon Staab 6923c2a8b7 Tweak modal, reduce storage on mobile 2025-11-03 14:43:27 -08:00
Jon Staab 1d3f32fb99 Only return error from attemptRelayAccess if there is a claim sent 2025-11-03 12:08:50 -08:00
Jon Staab 42a550788a Fix some alerts stuff 2025-11-03 11:10:16 -08:00
Jon Staab b1c68972c9 Streamline deriveRoom 2025-10-31 16:19:22 -07:00
Jon Staab 3978e32d5f Tweak access terminology, relay access attempts 2025-10-31 16:00:14 -07:00
Jon Staab ba2b5d182e Fix alerts 2025-10-31 14:51:59 -07:00
Jon Staab bef04fa899 Add holis to hosting suggestions 2025-10-31 14:02:52 -07:00
Jon Staab 4f8609421c Fix membership status 2025-10-31 12:10:16 -07:00
Jon Staab 07660c9d44 Re-work rooms derivation 2025-10-30 15:52:24 -07:00
Jon Staab a324dad2ba Rename channel to room 2025-10-30 15:36:14 -07:00
Jon Staab dbaa0f5d49 Rename room variables to h 2025-10-30 15:33:34 -07:00
Jon Staab 478721d349 Add room editing 2025-10-30 15:22:31 -07:00
Jon Staab a669a23dbc Tweak reaction buttons 2025-10-30 12:53:21 -07:00
Jon Staab cfeb6478cc Fix flapping subscription 2025-10-30 12:06:53 -07:00
Jon Staab 64539c49c1 Fix link, spinner animation 2025-10-30 07:20:09 -07:00
Jon Staab 0399ae37ec Move space create to its own page 2025-10-29 12:52:26 -07:00
Jon Staab 173a411a36 Update space create dialog 2025-10-29 11:18:27 -07:00
Jon Staab 62013a2ea2 Tweak mobile space menu 2025-10-28 16:50:15 -07:00
Jon Staab c82cf4a4c2 Update platform url 2025-10-28 16:08:48 -07:00
Jon Staab df42085be6 Sync messages at the space level 2025-10-28 15:46:25 -07:00
Jon Staab b09d3065ae Fix app url on capacitor deployments 2025-10-28 15:40:28 -07:00
Jon Staab c050f5a9e3 Update changelog 2025-10-28 15:37:36 -07:00
Jon Staab 78e6c0eca0 Bump version 2025-10-28 15:35:39 -07:00
Jon Staab da4da45348 Load rooms correctly 2025-10-28 14:53:44 -07:00
Jon Staab dc2af86db8 Bump welshman 2025-10-28 13:26:02 -07:00
Jon Staab 7502004aba Improve syncing 2025-10-28 11:29:59 -07:00
85 changed files with 1510 additions and 1199 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
VITE_BURROW_URL=
VITE_PLATFORM_URL=https://flotilla.social
VITE_PLATFORM_URL=https://app.flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla
+20
View File
@@ -1,5 +1,25 @@
# Changelog
# 1.5.0
* Restyle mobile dialogs
* Add room membership lists
* Add space membership lists
* Add edit room form
* Support closed/private/restricted/hidden rooms
* Add hosting services page
* Improve performance and UI
* Fix push notifications
* Improve error detection and handling
* Support invite links on discover page
* Add link to landlubber if user is admin
* Clear reply/share/edit on escape
# 1.4.1
* Improve data synchronization
* Fix app url on capacitor deployments
# 1.4.0
* Allow "editing" chat messages
+1 -1
View File
@@ -69,7 +69,7 @@ Here are a few important domain objects:
- Spaces are relays used as community groups. Their `url`s are core to a lot of data and components, and are frequently passed around from place to place.
- Chats are direct message conversations. There is currently some ambiguity in routing, since relays that don't support NIP 29 also have a "chat" tab, which uses vanilla NIP-C7.
- NIP 29 groups are variously called "rooms" and "channels". Conventionally, a "room" is a group id, while a "channel" as an object representing the group's metadata.
- NIP 29 groups are called "rooms". Conventionally, "h" is a group id, while a "room" as an object representing the group's metadata.
- "Alerts" are records of requests the user has made to be notified, following [this NIP](https://github.com/nostr-protocol/nips/pull/1796)
`app/core/requests` contains utilities related to loading data from the nostr network. This might include feed manager utilities, loaders, or listeners.
+2 -2
View File
@@ -8,8 +8,8 @@ If you would like to be interoperable with Flotilla, please check out this guide
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust.
- `VITE_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags.
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
- `VITE_PLATFORM_URL` - The url where the app will be hosted
- `VITE_PLATFORM_NAME` - The name of the app
- `VITE_PLATFORM_LOGO` - A logo url for the app
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 29
versionName "1.4.0"
versionCode 31
versionName "1.5.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
-4
View File
@@ -6,10 +6,6 @@ if [ -f .env.template ]; then
source .env.template
fi
if [ -f .env ]; then
source .env
fi
# Avoid overwriting env vars provided directly
# https://stackoverflow.com/a/69127685/1467342
eval "$temp_env"
+4 -4
View File
@@ -358,14 +358,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.5.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -384,14 +384,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -60,16 +60,16 @@
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.6.2",
"@welshman/content": "^0.6.2",
"@welshman/editor": "^0.6.2",
"@welshman/feeds": "^0.6.2",
"@welshman/lib": "^0.6.2",
"@welshman/net": "^0.6.2",
"@welshman/router": "^0.6.2",
"@welshman/signer": "^0.6.2",
"@welshman/store": "^0.6.2",
"@welshman/util": "^0.6.2",
"@welshman/app": "^0.6.4",
"@welshman/content": "^0.6.4",
"@welshman/editor": "^0.6.4",
"@welshman/feeds": "^0.6.4",
"@welshman/lib": "^0.6.4",
"@welshman/net": "^0.6.4",
"@welshman/router": "^0.6.4",
"@welshman/signer": "^0.6.4",
"@welshman/store": "^0.6.4",
"@welshman/util": "^0.6.4",
"compressorjs": "^1.2.1",
"daisyui": "^4.12.24",
"date-picker-svelte": "^2.16.0",
+76 -76
View File
@@ -72,35 +72,35 @@ importers:
specifier: ^0.6.8
version: 0.6.8(@sveltejs/kit@2.46.5(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.39.12)(vite@5.4.20(@types/node@24.7.2)(terser@5.44.0)))(svelte@5.39.12)(vite@5.4.20(@types/node@24.7.2)(terser@5.44.0)))(@vite-pwa/assets-generator@0.2.6)(vite-plugin-pwa@0.21.2(@vite-pwa/assets-generator@0.2.6)(vite@5.4.20(@types/node@24.7.2)(terser@5.44.0))(workbox-build@7.3.0)(workbox-window@7.3.0))
'@welshman/app':
specifier: ^0.6.2
version: 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
specifier: ^0.6.4
version: 0.6.4(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/content':
specifier: ^0.6.2
version: 0.6.2(typescript@5.9.3)
specifier: ^0.6.4
version: 0.6.4(typescript@5.9.3)
'@welshman/editor':
specifier: ^0.6.2
version: 0.6.2(@tiptap/extension-image@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(linkifyjs@4.3.2)(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(typescript@5.9.3)
specifier: ^0.6.4
version: 0.6.4(@tiptap/extension-image@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(linkifyjs@4.3.2)(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(typescript@5.9.3)
'@welshman/feeds':
specifier: ^0.6.2
version: 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
specifier: ^0.6.4
version: 0.6.4(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/lib':
specifier: ^0.6.2
version: 0.6.2
specifier: ^0.6.4
version: 0.6.4
'@welshman/net':
specifier: ^0.6.2
version: 0.6.2(typescript@5.9.3)(ws@8.18.3)
specifier: ^0.6.4
version: 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/router':
specifier: ^0.6.2
version: 0.6.2(typescript@5.9.3)(ws@8.18.3)
specifier: ^0.6.4
version: 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/signer':
specifier: ^0.6.2
version: 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
specifier: ^0.6.4
version: 0.6.4(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/store':
specifier: ^0.6.2
version: 0.6.2(typescript@5.9.3)(ws@8.18.3)
specifier: ^0.6.4
version: 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/util':
specifier: ^0.6.2
version: 0.6.2(typescript@5.9.3)
specifier: ^0.6.4
version: 0.6.4(typescript@5.9.3)
compressorjs:
specifier: ^1.2.1
version: 1.2.1
@@ -1692,38 +1692,38 @@ packages:
'@vite-pwa/assets-generator':
optional: true
'@welshman/app@0.6.2':
resolution: {integrity: sha512-URvw8IMCkZpotyKyCKYyyHkNmF9ghYUAelZmfQrC0YttiDUVQGE0qq5/3gwZ1HAy6f9hbMWH2hfPLVnaEh4K2A==}
'@welshman/app@0.6.4':
resolution: {integrity: sha512-hmUdVK+mntAYLjoH8Pv4GKNgzP+4vzyFJc0ntvoiKmOZN++/0fZWYPDrwvQ2laW1NEoig8DyUuJn8KH1XTiwkQ==}
'@welshman/content@0.6.2':
resolution: {integrity: sha512-eiDpxKhm87i2JCsINtt8SGkt58JCL0UB6i0rzHsyZC1oOSM0W3OJ3NkZGDNooneDe02GpvgzDAAJa3m8QgnnzA==}
'@welshman/content@0.6.4':
resolution: {integrity: sha512-sesFIa1DIzxpXx6drF6CFdjiWFZ9Z9DrKP34JBaPfb3Vi6ewh43VBZx2lM52S1Nx3/BMEv2rh50PNcvNs2WYFQ==}
'@welshman/editor@0.6.2':
resolution: {integrity: sha512-+sCnDCvjPLy94maRPLJdZEdQgys6XyYPOmxpXaj/AmkhqGIGovA/7YiIB86vS1NHmRy02hgM/eMV311jaVe6ww==}
'@welshman/editor@0.6.4':
resolution: {integrity: sha512-G03AlY/n6ANG9zosVOd833DPXJheLbdWcE6shhYyscdOitW7KFwKYAaUOug5NOhBsonGtD8O7lKHryPo6qBxyw==}
'@welshman/feeds@0.6.2':
resolution: {integrity: sha512-bOgcLwVd/n5HlhxuflBZLXJZfKhQAJDbYh40sNr1dtKh4eM6vd5ujMZyj8D2OwdfazOAVX3ZwRenJCdy5+gwVw==}
'@welshman/feeds@0.6.4':
resolution: {integrity: sha512-Mjnd4/bBQvc7szcXjCy6fG3tDDJv6J+PdCOWLgE28BIGmG3yzMPvKDEYm1zcyDuZfa/zJp2o4NYg9FWVpYdW9A==}
'@welshman/lib@0.6.2':
resolution: {integrity: sha512-1GpCOr0pXTDdy87PEvAV0+lvjgQbFODU5RsNM0LTyeU0885uzPHrfoJbZoNXayY9klwuvS9NBHFkUN7lySITzA==}
'@welshman/lib@0.6.4':
resolution: {integrity: sha512-+K5OC2jctVLTOXjENNoB7rG70pkYI7dZysjE2vD2EJLrN1XaV7MtGHpONsKJQi4f1yKOhXeUMPrPu/lKADcSLg==}
engines: {node: '>=12.0.0'}
'@welshman/net@0.6.2':
resolution: {integrity: sha512-MX2nGqH2/P/Q9WeIiN9AHo4/n2S4mABE6gCACB5vA1v3FdwKjinN/otoUJGo+fTlaIMJHnxoDYV4vOT3F9mZuA==}
'@welshman/net@0.6.4':
resolution: {integrity: sha512-+4x9HnArPR6tLGt/bptOsxx/s61UpWEVO5+epLfS6t8sW7II7K8j/+d/SVwF3nP+TI+6M5zjaEOG9cOprS0EmQ==}
'@welshman/router@0.6.2':
resolution: {integrity: sha512-dyTFn5h1oxP7bh/G+kydqfvXTPjmq2aZ1ikwJ2rOsCaNNqBnm/d9o6r9knZoWGJpLD6a5szY5lA9Vo9DtUuouA==}
'@welshman/router@0.6.4':
resolution: {integrity: sha512-o5YHd8eOCziuhq7p2dZMlQ9ocosjJdv7RtAL4ofRhfT31eGmPtexkwTEem90+Lr5gdgsRsszPwUFS7AbNBARRg==}
'@welshman/signer@0.6.2':
resolution: {integrity: sha512-UYCr0c9xj2qbknDD5HH94+cfdX7v27PGJPZ9V7JuQQHdDzK8LUOLXf5NvGqcTbAb6IlUnTGbvA19oJZP6l0vNw==}
'@welshman/signer@0.6.4':
resolution: {integrity: sha512-Oby/aMJvsEEdSNday4dC+/M0ZOABkYQddvfkDYvR2snBS326+bCuABf3Fd9eoHj7MB+wE2qQ96tiX4zX/RWS5Q==}
peerDependencies:
nostr-signer-capacitor-plugin: ~0.0.4
'@welshman/store@0.6.2':
resolution: {integrity: sha512-ZRELLUtadbUoLV678qJ8Lt2KmcCe+Qi3frsKJmPW5HeuPHKgNJBY9QgqYK2D07T42nLGqrqLdVfv3Mz0xp3eGA==}
'@welshman/store@0.6.4':
resolution: {integrity: sha512-uDlpLt/f/nf9voO6K/h97o+VzhfReogUB5ZMljsW49rUsw/P9ohidPYcLkvIjyWaWcRvuRxJK4cipVXXpqmukw==}
'@welshman/util@0.6.2':
resolution: {integrity: sha512-stBlkzlso4A0mpicQjwrHvfXdbZ5XTT2K4iuTFe9a4DN5bKkDiHYEWvEjbd3751TJK1BKH9snOfgdKuxnkvSeg==}
'@welshman/util@0.6.4':
resolution: {integrity: sha512-T0KapD05GncO5WwaK7X7JxaYXi0AVsOyhUi7anhVCsXg/ubFd8Giqa5vV/S9HcS95C9nCcqcoKlbh9kHPvVK5g==}
'@xml-tools/parser@1.0.11':
resolution: {integrity: sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA==}
@@ -6651,16 +6651,16 @@ snapshots:
optionalDependencies:
'@vite-pwa/assets-generator': 0.2.6
'@welshman/app@0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
'@welshman/app@0.6.4(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
dependencies:
'@types/throttle-debounce': 5.0.2
'@welshman/feeds': 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/lib': 0.6.2
'@welshman/net': 0.6.2(typescript@5.9.3)(ws@8.18.3)
'@welshman/router': 0.6.2(typescript@5.9.3)(ws@8.18.3)
'@welshman/signer': 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/store': 0.6.2(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.2(typescript@5.9.3)
'@welshman/feeds': 0.6.4(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/lib': 0.6.4
'@welshman/net': 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/router': 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/signer': 0.6.4(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/store': 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.4(typescript@5.9.3)
fuse.js: 7.1.0
svelte: 4.2.20
throttle-debounce: 5.0.2
@@ -6669,14 +6669,14 @@ snapshots:
- typescript
- ws
'@welshman/content@0.6.2(typescript@5.9.3)':
'@welshman/content@0.6.4(typescript@5.9.3)':
dependencies:
'@braintree/sanitize-url': 7.1.1
nostr-tools: 2.17.0(typescript@5.9.3)
transitivePeerDependencies:
- typescript
'@welshman/editor@0.6.2(@tiptap/extension-image@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(linkifyjs@4.3.2)(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(typescript@5.9.3)':
'@welshman/editor@0.6.4(@tiptap/extension-image@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(linkifyjs@4.3.2)(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(typescript@5.9.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-code': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
@@ -6691,8 +6691,8 @@ snapshots:
'@tiptap/extension-text': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/pm': 2.26.3
'@tiptap/suggestion': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@welshman/lib': 0.6.2
'@welshman/util': 0.6.2(typescript@5.9.3)
'@welshman/lib': 0.6.4
'@welshman/util': 0.6.4(typescript@5.9.3)
nostr-editor: 1.0.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/extension-image@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(linkifyjs@4.3.2)(nostr-tools@2.17.0(typescript@5.9.3))(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))
nostr-tools: 2.17.0(typescript@5.9.3)
tippy.js: 6.3.7
@@ -6707,71 +6707,71 @@ snapshots:
- tiptap-markdown
- typescript
'@welshman/feeds@0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
'@welshman/feeds@0.6.4(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
dependencies:
'@welshman/lib': 0.6.2
'@welshman/net': 0.6.2(typescript@5.9.3)(ws@8.18.3)
'@welshman/router': 0.6.2(typescript@5.9.3)(ws@8.18.3)
'@welshman/signer': 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.2(typescript@5.9.3)
'@welshman/lib': 0.6.4
'@welshman/net': 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/router': 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/signer': 0.6.4(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.4(typescript@5.9.3)
trava: 1.2.1
transitivePeerDependencies:
- nostr-signer-capacitor-plugin
- typescript
- ws
'@welshman/lib@0.6.2':
'@welshman/lib@0.6.4':
dependencies:
'@scure/base': 1.2.6
'@types/events': 3.0.3
events: 3.3.0
'@welshman/net@0.6.2(typescript@5.9.3)(ws@8.18.3)':
'@welshman/net@0.6.4(typescript@5.9.3)(ws@8.18.3)':
dependencies:
'@welshman/lib': 0.6.2
'@welshman/util': 0.6.2(typescript@5.9.3)
'@welshman/lib': 0.6.4
'@welshman/util': 0.6.4(typescript@5.9.3)
events: 3.3.0
isomorphic-ws: 5.0.0(ws@8.18.3)
transitivePeerDependencies:
- typescript
- ws
'@welshman/router@0.6.2(typescript@5.9.3)(ws@8.18.3)':
'@welshman/router@0.6.4(typescript@5.9.3)(ws@8.18.3)':
dependencies:
'@welshman/lib': 0.6.2
'@welshman/net': 0.6.2(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.2(typescript@5.9.3)
'@welshman/lib': 0.6.4
'@welshman/net': 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.4(typescript@5.9.3)
transitivePeerDependencies:
- typescript
- ws
'@welshman/signer@0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
'@welshman/signer@0.6.4(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
dependencies:
'@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
'@welshman/lib': 0.6.2
'@welshman/net': 0.6.2(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.2(typescript@5.9.3)
'@welshman/lib': 0.6.4
'@welshman/net': 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.4(typescript@5.9.3)
nostr-signer-capacitor-plugin: 0.0.4(@capacitor/core@7.4.3)
nostr-tools: 2.17.0(typescript@5.9.3)
transitivePeerDependencies:
- typescript
- ws
'@welshman/store@0.6.2(typescript@5.9.3)(ws@8.18.3)':
'@welshman/store@0.6.4(typescript@5.9.3)(ws@8.18.3)':
dependencies:
'@welshman/lib': 0.6.2
'@welshman/net': 0.6.2(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.2(typescript@5.9.3)
'@welshman/lib': 0.6.4
'@welshman/net': 0.6.4(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.4(typescript@5.9.3)
svelte: 4.2.20
transitivePeerDependencies:
- typescript
- ws
'@welshman/util@0.6.2(typescript@5.9.3)':
'@welshman/util@0.6.4(typescript@5.9.3)':
dependencies:
'@types/ws': 8.18.1
'@welshman/lib': 0.6.2
'@welshman/lib': 0.6.4
js-base64: 3.7.8
nostr-tools: 2.17.0(typescript@5.9.3)
nostr-wasm: 0.1.0
+1 -1
View File
@@ -395,7 +395,7 @@ progress[value]::-webkit-progress-value {
/* chat view */
.chat__compose {
@apply cb cw fixed;
@apply cb cw fixed z-compose;
}
.chat__scroll-down {
@@ -5,7 +5,7 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import RoomName from "@app/components/RoomName.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
@@ -25,7 +25,7 @@
const {url, event, showRoom, showActivity}: Props = $props()
const room = getTagValue("h", event.tags)
const h = getTagValue("h", event.tags)
const path = makeCalendarPath(url, event.id)
const shouldProtect = canEnforceNip70(url)
@@ -39,9 +39,9 @@
</script>
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if room && showRoom}
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<ChannelName {room} {url} />
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
@@ -4,13 +4,13 @@
type Props = {
url: string
room?: string
h?: string
}
const {url, room}: Props = $props()
const {url, h}: Props = $props()
</script>
<CalendarEventForm {url} {room}>
<CalendarEventForm {url} {h}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
+4 -4
View File
@@ -23,7 +23,7 @@
type Props = {
url: string
room?: string
h?: string
header: Snippet
initialValues?: {
d: string
@@ -35,7 +35,7 @@
}
}
const {url, room, header, initialValues}: Props = $props()
const {url, h, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -85,8 +85,8 @@
tags.push(PROTECTED)
}
if (room) {
tags.push(["h", room])
if (h) {
tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
+7 -5
View File
@@ -5,7 +5,7 @@
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ChannelLink from "@app/components/ChannelLink.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import {makeCalendarPath} from "@app/util/routes"
type Props = {
@@ -15,16 +15,18 @@
const {url, event}: Props = $props()
const room = getTagValue("h", event.tags)
const h = getTagValue("h", event.tags)
</script>
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
<Link
class="col-3 card2 bg-alt w-full cursor-pointer shadow-xl"
href={makeCalendarPath(url, event.id)}>
<CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
{#if room}
in <ChannelLink {url} {room} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<CalendarEventActions showActivity {url} {event} />
-7
View File
@@ -1,7 +0,0 @@
<script lang="ts">
import {channelsById, makeChannelId} from "@app/core/state"
const {url, room} = $props()
</script>
{$channelsById.get(makeChannelId(url, room))?.name || room}
+5 -5
View File
@@ -13,16 +13,16 @@
type Props = {
url: string
onClick: () => void
room?: string
h?: string
}
const {url, room, onClick}: Props = $props()
const {url, h, onClick}: Props = $props()
const createGoal = () => pushModal(GoalCreate, {url, room})
const createGoal = () => pushModal(GoalCreate, {url, h})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, room})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h})
const createThread = () => pushModal(ThreadCreate, {url, room})
const createThread = () => pushModal(ThreadCreate, {url, h})
let ul: Element
+6 -6
View File
@@ -8,29 +8,29 @@
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {goToEvent} from "@app/util/routes"
import {displayChannel} from "@app/core/state"
import {displayRoom} from "@app/core/state"
type Props = {
url: string
room?: string
h?: string
events: TrustedEvent[]
latest: TrustedEvent
earliest: TrustedEvent
participants: string[]
}
const {url, room, events, latest, earliest, participants}: Props = $props()
const {url, h, events, latest, earliest, participants}: Props = $props()
</script>
<Button class="card2 bg-alt" onclick={() => goToEvent(earliest)}>
<Button class="card2 bg-alt shadow-lg" onclick={() => goToEvent(earliest)}>
<div class="flex flex-col gap-3">
<div class="flex items-start gap-3">
<ProfileCircle pubkey={earliest.pubkey} size={10} />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm opacity-70">
{#if room}
{#if h}
<span class="truncate font-medium text-blue-400">
#{displayChannel(url, room)}
#{displayRoom(url, h)}
</span>
<span class="opacity-50"></span>
{/if}
+9 -9
View File
@@ -9,8 +9,8 @@
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {channelsByUrl} from "@app/core/state"
import RoomName from "@app/components/RoomName.svelte"
import {roomsByUrl} from "@app/core/state"
import {makeRoomPath} from "@app/util/routes"
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props()
@@ -22,8 +22,8 @@
goto(makeRoomPath(url, selection), {replaceState: true})
}
const toggleRoom = (room: string) => {
selection = room === selection ? "" : room
const toggleRoom = (h: string) => {
selection = h === selection ? "" : h
}
let selection = $state("")
@@ -39,14 +39,14 @@
{/snippet}
</ModalHeader>
<div class="grid grid-cols-3 gap-2">
{#each $channelsByUrl.get(url) || [] as channel (channel.room)}
{#each $roomsByUrl.get(url) || [] as room (room.h)}
<button
type="button"
class="btn"
class:btn-neutral={selection !== channel.room}
class:btn-primary={selection === channel.room}
onclick={() => toggleRoom(channel.room)}>
#<ChannelName {...channel} />
class:btn-neutral={selection !== room.h}
class:btn-primary={selection === room.h}
onclick={() => toggleRoom(room.h)}>
#<RoomName {...room} />
</button>
{/each}
</div>
+5 -5
View File
@@ -6,7 +6,7 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeGoalPath, makeSpacePath} from "@app/util/routes"
@@ -20,7 +20,7 @@
const {url, event, showRoom, showActivity}: Props = $props()
const path = makeGoalPath(url, event.id)
const room = getTagValue("h", event.tags)
const h = getTagValue("h", event.tags)
const shouldProtect = canEnforceNip70(url)
const deleteReaction = async (event: TrustedEvent) =>
@@ -31,9 +31,9 @@
</script>
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if room && showRoom}
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<ChannelName {room} {url} />
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
+4 -4
View File
@@ -20,10 +20,10 @@
type Props = {
url: string
room?: string
h?: string
}
const {url, room}: Props = $props()
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -64,8 +64,8 @@
tags.push(PROTECTED)
}
if (room) {
tags.push(["h", room])
if (h) {
tags.push(["h", h])
}
publishThunk({
+5 -5
View File
@@ -6,7 +6,7 @@
import ProfileLink from "@app/components/ProfileLink.svelte"
import GoalActions from "@app/components/GoalActions.svelte"
import GoalSummary from "@app/components/GoalSummary.svelte"
import ChannelLink from "@app/components/ChannelLink.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import {makeGoalPath} from "@app/util/routes"
type Props = {
@@ -17,10 +17,10 @@
const {url, event}: Props = $props()
const summary = getTagValue("summary", event.tags)
const room = getTagValue("h", event.tags)
const h = getTagValue("h", event.tags)
</script>
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
<Link class="col-2 card2 bg-alt w-full cursor-pointer shadow-xl" href={makeGoalPath(url, event.id)}>
<p class="text-2xl">{event.content}</p>
<Content
event={{content: summary, tags: event.tags}}
@@ -32,8 +32,8 @@
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
{#if room}
in <ChannelLink {url} {room} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<GoalActions showActivity {url} {event} />
+17 -7
View File
@@ -12,6 +12,7 @@
import Letter from "@assets/icons/letter.svg?dataurl"
import Login from "@assets/icons/login-3.svg?dataurl"
import History from "@assets/icons/history.svg?dataurl"
import Tuning2 from "@assets/icons/tuning-2.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
@@ -38,7 +39,7 @@
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {
ENABLE_ZAPS,
MESSAGE_FILTER,
CONTENT_KINDS,
deriveSpaceMembers,
deriveEventsForUrl,
deriveUserRooms,
@@ -47,6 +48,7 @@
hasNip29,
alerts,
deriveUserCanCreateRoom,
deriveUserIsSpaceAdmin,
} from "@app/core/state"
import {notifications} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
@@ -63,9 +65,10 @@
const otherRooms = deriveOtherRooms(url)
const members = deriveSpaceMembers(url)
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const spaceKinds = derived(
deriveEventsForUrl(url, [MESSAGE_FILTER]),
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
$events => new Set($events.map(e => e.kind)),
)
@@ -149,7 +152,14 @@
View Members ({$members.length})
</Button>
</li>
{#if $relay?.pubkey}
{#if $userIsAdmin}
<li>
<Link external href="https://landlubber.coracle.social">
<Icon icon={Tuning2} />
Manage Space
</Link>
</li>
{:else if $relay?.pubkey}
<li>
<Link href={makeChatPath([$relay.pubkey])}>
<Icon icon={Letter} />
@@ -216,8 +226,8 @@
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
{#each $userRooms as h, i (h)}
<MenuSpaceRoomItem {replaceState} notify {url} {h} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
@@ -229,8 +239,8 @@
{/if}
</SecondaryNavHeader>
{/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} {url} {room} />
{#each $otherRooms as h, i (h)}
<MenuSpaceRoomItem {replaceState} {url} {h} />
{/each}
{#if $canCreateRoom}
<SecondaryNavItem {replaceState} onclick={addRoom}>
+5 -24
View File
@@ -1,43 +1,24 @@
<script lang="ts">
import Lock from "@assets/icons/lock-keyhole.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {makeRoomPath} from "@app/util/routes"
import {deriveChannel} from "@app/core/state"
import {notifications} from "@app/util/notifications"
interface Props {
url: any
room: any
h: any
notify?: boolean
replaceState?: boolean
}
const {url, room, notify = false, replaceState = false}: Props = $props()
const {url, h, notify = false, replaceState = false}: Props = $props()
const path = makeRoomPath(url, room)
const channel = deriveChannel(url, room)
const path = makeRoomPath(url, h)
</script>
<SecondaryNavItem
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
{#if $channel?.picture}
{@const src = $channel.picture}
{#if src.match("\.(png|svg)$") || src.match("image/(png|svg)")}
<Icon icon={src} />
{:else}
<img alt="Room icon" {src} class="h-6 w-6 rounded-lg" />
{/if}
{:else if $channel?.closed || $channel?.private}
<Icon icon={Lock} />
{:else}
<Icon icon={Hashtag} />
{/if}
<div class="min-w-0 overflow-hidden text-ellipsis">
<ChannelName {url} {room} />
</div>
<RoomNameWithImage {url} {h} />
</SecondaryNavItem>
-35
View File
@@ -1,35 +0,0 @@
<script lang="ts">
import Compass from "@assets/icons/compass.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Divider from "@lib/components/Divider.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
</script>
<div class="column menu gap-2">
{#each PLATFORM_RELAYS as url (url)}
<MenuSpacesItem {url} />
{:else}
{#if $userSpaceUrls.length > 0}
{#each $userSpaceUrls as url (url)}
<MenuSpacesItem {url} />
{/each}
<Divider />
{/if}
<Link href="/discover">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Compass} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Explore Spaces</div>
{/snippet}
{#snippet info()}
<div>Join create, or browse spaces</div>
{/snippet}
</CardButton>
</Link>
{/each}
</div>
+8 -2
View File
@@ -4,9 +4,15 @@
import Dialog from "@lib/components/Dialog.svelte"
import {modal, clearModals} from "@app/util/modal"
const closeModals = () => {
if ($modal && !$modal.options.noEscape) {
clearModals()
}
}
const onKeyDown = (e: any) => {
if (e.code === "Escape" && e.target === document.body) {
clearModals()
closeModals()
}
}
@@ -27,7 +33,7 @@
instance = mount(wrapper as any, {
target: element,
props: {
onClose: clearModals,
onClose: closeModals,
children: createRawSnippet(() => ({
render: () => "<div></div>",
setup: (target: Element) => {
+2 -10
View File
@@ -7,9 +7,7 @@
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 MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
@@ -31,9 +29,6 @@
const {children}: Props = $props()
const showSpacesMenu = () =>
$userSpaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
const showSettingsMenu = () => pushModal(MenuSettings)
@@ -118,7 +113,7 @@
<div
class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2">
<div class="flex gap-2 sm:gap-8">
<div class="flex gap-2 sm:gap-6">
<PrimaryNavItem title="Home" href="/home">
<Avatar icon={HomeSmile} class="!h-10 !w-10" />
</PrimaryNavItem>
@@ -129,10 +124,7 @@
<Avatar icon={Letter} class="!h-10 !w-10" />
</PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem
title="Spaces"
onclick={showSpacesMenu}
notification={anySpaceNotifications}>
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
<Avatar icon={SettingsMinimalistic} class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
@@ -1,24 +1,19 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
import {encodeRelay} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
import {lastPageBySpaceUrl} from "@app/util/history"
import {makeSpacePath, goToSpace} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
const {url} = $props()
const path = makeSpacePath(url)
const onClick = () => goto(lastPageBySpaceUrl.get(encodeRelay(url)) || path)
const onClick = () => goToSpace(url)
</script>
<PrimaryNavItem
onclick={onClick}
title={displayRelayUrl(url)}
class="tooltip-right"
notification={$notifications.has(path)}>
notification={$notifications.has(makeSpacePath(url))}>
<SpaceAvatar {url} />
</PrimaryNavItem>
+19 -8
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import {groupBy, sum, uniq, uniqBy, batch, displayList} from "@welshman/lib"
@@ -134,10 +135,15 @@
<button
type="button"
data-tip={tooltip}
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full text-xs font-normal {reactionClass}"
class:tooltip={!noTooltip && !isMobile}
class:btn-neutral={!isOwn}
class:btn-primary={isOwn}>
class={cx(
reactionClass,
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full text-xs font-normal",
{
tooltip: !noTooltip && !isMobile,
"border-neutral-content/20": !isOwn,
"btn-primary": isOwn,
},
)}>
<Reaction event={zaps[0].request} />
<span>{amount}</span>
</button>
@@ -151,10 +157,15 @@
<button
type="button"
data-tip={tooltip}
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full font-normal {reactionClass}"
class:tooltip={!noTooltip && !isMobile}
class:btn-neutral={!isOwn}
class:btn-primary={isOwn}
class={cx(
reactionClass,
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal",
{
tooltip: !noTooltip && !isMobile,
"border-neutral-content/20": !isOwn,
"btn-primary": isOwn,
},
)}
onclick={stopPropagation(preventDefault(onClick))}>
<Reaction event={events[0]} />
{#if events.length > 1}
@@ -16,13 +16,14 @@
type Props = {
url?: string
room?: string
h?: string
content?: string
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
}
const {url, room, content, onEditPrevious, onSubmit}: Props = $props()
const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props()
const autofocus = !isMobile
@@ -34,6 +35,10 @@
editor.then(ed => ed.getText({blockSeparator: "\n"}) === "")
const handleKeyDown = async (event: KeyboardEvent) => {
if (event.key === "Escape") {
onEscape?.()
}
if (event.key === "ArrowUp" && (await canEnterEditPrevious())) {
onEditPrevious?.()
}
@@ -74,7 +79,7 @@
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<form class="relative flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<div class="join">
<Button
data-tip="Add an image"
@@ -90,7 +95,7 @@
<Tippy
bind:popover
component={ComposeMenu}
props={{url, room, onClick: hidePopover}}
props={{url, h, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Button
data-tip="More options"
@@ -12,10 +12,10 @@
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
class="relative flex h-8 items-center justify-between border-l-2 border-solid border-primary bg-base-300 px-2 pr-7 text-xs"
transition:slide>
<p class="text-primary">Editing message</p>
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Button onclick={clear} class="flex items-center">
<Icon icon={CloseCircle} />
</Button>
</div>
+28 -162
View File
@@ -1,181 +1,47 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {uniqBy, nth} from "@welshman/lib"
import {displayRelayUrl, makeRoomMeta} from "@welshman/util"
import {deriveRelay, waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import type {RoomMeta} from "@welshman/util"
import {displayRelayUrl} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault, compressFile} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import IconPickerButton from "@lib/components/IconPickerButton.svelte"
import {hasNip29, loadChannel} from "@app/core/state"
import RoomForm from "@app/components/RoomForm.svelte"
import {makeSpacePath} from "@app/util/routes"
import {pushToast} from "@app/util/toast"
import {uploadFile} from "@app/core/commands"
const {url} = $props()
const room = makeRoomMeta()
const relay = deriveRelay(url)
const back = () => history.back()
const tryCreate = async () => {
room.tags = uniqBy(nth(0), [...room.tags, ["name", name]])
if (imageFile) {
const {error, result} = await uploadFile(imageFile)
if (error) {
return pushToast({theme: "error", message: error})
}
room.tags.push(["picture", result.url, ...result.tags])
} else if (selectedIcon) {
room.tags.push(["picture", selectedIcon])
}
const createMessage = await waitForThunkError(createRoom(url, room))
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
return pushToast({theme: "error", message: createMessage})
}
const editMessage = await waitForThunkError(editRoom(url, room))
if (editMessage) {
return pushToast({theme: "error", message: editMessage})
}
const joinMessage = await waitForThunkError(joinRoom(url, room))
if (joinMessage && !joinMessage.includes("already")) {
return pushToast({theme: "error", message: joinMessage})
}
await loadChannel(url, room.id)
goto(makeSpacePath(url, room.id))
}
const create = async () => {
loading = true
try {
await tryCreate()
} finally {
loading = false
}
}
let name = $state("")
let loading = $state(false)
let imageFile = $state<File | undefined>()
let imagePreview = $state<string | undefined>()
let selectedIcon = $state<string | undefined>()
const handleImageUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (file && file.type.startsWith("image/")) {
selectedIcon = undefined
imageFile = await compressFile(file, {maxWidth: 64, maxHeight: 64})
const reader = new FileReader()
reader.onload = e => {
imagePreview = e.target?.result as string
}
reader.readAsDataURL(imageFile)
}
}
const handleIconSelect = (iconUrl: string) => {
imageFile = undefined
imagePreview = undefined
selectedIcon = iconUrl
}
const onsubmit = (room: RoomMeta) => goto(makeSpacePath(url, room.h))
</script>
<form class="column gap-4" onsubmit={preventDefault(create)}>
<ModalHeader>
{#snippet title()}
<div>Create a Room</div>
{/snippet}
{#snippet info()}
<div>
On <span class="text-primary">{displayRelayUrl(url)}</span>
</div>
{/snippet}
</ModalHeader>
{#if hasNip29($relay)}
<FieldInline>
{#snippet label()}
<p>Room Name</p>
<RoomForm {url} {onsubmit}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
<div>Create a Room</div>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Hashtag} />
<input bind:value={name} class="grow" type="text" />
</label>
{/snippet}
</FieldInline>
<div class="flex items-center justify-between">
<p class="font-bold">Room Icon</p>
<div class="flex items-center gap-4">
{#if imagePreview}
<div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span>
<img
src={imagePreview}
alt="Room icon preview"
class="h-8 w-8 rounded-lg object-cover" />
</div>
{:else if selectedIcon}
<div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span>
<Icon icon={selectedIcon} class="h-8 w-8" />
</div>
{:else}
<span class="text-sm opacity-75">No icon selected</span>
{/if}
<div class="flex gap-2">
<IconPickerButton onSelect={handleIconSelect} class="btn btn-primary btn-sm">
<Icon icon={StickerSmileSquare} size={4} />
Select
</IconPickerButton>
<label class="btn btn-neutral btn-sm cursor-pointer">
<Icon icon={UploadMinimalistic} size={4} />
Upload
<input type="file" accept="image/*" class="hidden" onchange={handleImageUpload} />
</label>
{#snippet info()}
<div>
On <span class="text-primary">{displayRelayUrl(url)}</span>
</div>
</div>
</div>
{:else}
<p class="bg-alt card2 row-2">
<Icon icon={Danger} />
This relay does not support creating rooms.
</p>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={!name || loading || !hasNip29($relay)}>
<Spinner {loading}>Create Room</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
{/snippet}
</ModalHeader>
{/snippet}
{#snippet footer({loading})}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Create Room</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
{/snippet}
</RoomForm>
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import {goto} from "$app/navigation"
import type {RoomMeta} from "@welshman/util"
import {displayRelayUrl} from "@welshman/util"
import {deleteRoom, waitForThunkError, repository} from "@welshman/app"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RoomForm from "@app/components/RoomForm.svelte"
import {deriveRoom} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
type Props = {
url: string
h: string
}
const {url, h}: Props = $props()
const room = deriveRoom(url, h)
const back = () => history.back()
const onsubmit = (room: RoomMeta) => goto(makeSpacePath(url, h))
const startDelete = () =>
pushModal(Confirm, {
title: "Are you sure you want to delete this room?",
message:
"This room will no longer be accessible to space members, and all messages posted to it will be deleted.",
confirm: async () => {
const thunk = deleteRoom(url, $room)
const message = await waitForThunkError(thunk)
if (message) {
repository.removeEvent(thunk.event.id)
pushToast({theme: "error", message})
} else {
goto(makeSpacePath(url))
}
},
})
</script>
<RoomForm {url} {onsubmit} initialValues={$room}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
<div>Edit a Room</div>
{/snippet}
{#snippet info()}
<div>
On <span class="text-primary">{displayRelayUrl(url)}</span>
</div>
{/snippet}
</ModalHeader>
{/snippet}
{#snippet footer({loading})}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<div class="flex gap-2">
<Button class="btn btn-outline btn-error" onclick={startDelete}>
<Icon icon={TrashBin2} />
<span class="hidden md:inline">Delete Room</span>
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Save Changes</Spinner>
</Button>
</div>
</ModalFooter>
{/snippet}
</RoomForm>
+206
View File
@@ -0,0 +1,206 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {RoomMeta} from "@welshman/util"
import {makeRoomMeta} from "@welshman/util"
import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault, compressFile} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import IconPickerButton from "@lib/components/IconPickerButton.svelte"
import {pushToast} from "@app/util/toast"
import {uploadFile} from "@app/core/commands"
type Props = {
url: string
header: Snippet
footer: Snippet<[{loading: boolean}]>
onsubmit: (room: RoomMeta) => void
initialValues?: RoomMeta
}
const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props()
const values = $state(initialValues)
const submit = async () => {
const room = $state.snapshot(values)
if (imageFile) {
const {error, result} = await uploadFile(imageFile)
if (error) {
return pushToast({theme: "error", message: error})
}
room.picture = result.url
room.pictureMeta = result.tags
} else if (selectedIcon) {
room.picture = selectedIcon
}
const createMessage = await waitForThunkError(createRoom(url, room))
if (createMessage && !createMessage.includes("already")) {
return pushToast({theme: "error", message: createMessage})
}
const editMessage = await waitForThunkError(editRoom(url, room))
if (editMessage) {
return pushToast({theme: "error", message: editMessage})
}
const joinMessage = await waitForThunkError(joinRoom(url, room))
if (joinMessage && !joinMessage.includes("already")) {
return pushToast({theme: "error", message: joinMessage})
}
onsubmit(room)
}
const trySubmit = async () => {
loading = true
try {
await submit()
} finally {
loading = false
}
}
let loading = $state(false)
let imageFile = $state<File | undefined>()
let imagePreview = $state(initialValues.picture)
let selectedIcon = $state<string | undefined>()
const handleImageUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (file && file.type.startsWith("image/")) {
selectedIcon = undefined
imageFile = await compressFile(file, {maxWidth: 64, maxHeight: 64})
const reader = new FileReader()
reader.onload = e => {
imagePreview = e.target?.result as string
}
reader.readAsDataURL(imageFile)
}
}
const handleIconSelect = (iconUrl: string) => {
imageFile = undefined
imagePreview = undefined
selectedIcon = iconUrl
}
</script>
<form class="column gap-4" onsubmit={preventDefault(trySubmit)}>
{@render header()}
<FieldInline>
{#snippet label()}
<p>Icon</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
{#if imagePreview}
<div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span>
<ImageIcon src={imagePreview} alt="Room icon preview" />
</div>
{:else if selectedIcon}
<div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span>
<Icon icon={selectedIcon} class="h-8 w-8" />
</div>
{:else}
<span class="text-sm opacity-75">No icon selected</span>
{/if}
<div class="flex gap-2">
<IconPickerButton onSelect={handleIconSelect} class="btn btn-primary btn-sm">
<Icon icon={StickerSmileSquare} size={4} />
Select
</IconPickerButton>
<label class="btn btn-neutral btn-sm cursor-pointer">
<Icon icon={UploadMinimalistic} size={4} />
Upload
<input type="file" accept="image/*" class="hidden" onchange={handleImageUpload} />
</label>
</div>
</div>
</div>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Name</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
{#if imagePreview}
<ImageIcon src={imagePreview} alt="Room icon preview" />
{:else if selectedIcon}
<Icon icon={selectedIcon} class="h-8 w-8" />
{:else}
<Icon icon={Hashtag} />
{/if}
<input bind:value={values.name} class="grow" type="text" />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Description</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={values.about} class="grow" type="text" />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<strong>Restricted</strong>
{/snippet}
{#snippet input()}
<input type="checkbox" class="checkbox" bind:checked={values.isRestricted} />
<span class="text-sm opacity-75">Only allow members to send messages</span>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<strong>Private</strong>
{/snippet}
{#snippet input()}
<input type="checkbox" class="checkbox" bind:checked={values.isPrivate} />
<span class="text-sm opacity-75">Only allow members to read messages</span>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<strong>Hidden</strong>
{/snippet}
{#snippet input()}
<input type="checkbox" class="checkbox" bind:checked={values.isHidden} />
<span class="text-sm opacity-75">Hide this group from non-members</span>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<strong>Closed</strong>
{/snippet}
{#snippet input()}
<input type="checkbox" class="checkbox" bind:checked={values.isClosed} />
<span class="text-sm opacity-75">Ignore requests to join</span>
{/snippet}
</FieldInline>
{@render footer({loading})}
</form>
@@ -23,14 +23,14 @@
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelItemZapButton from "@app/components/ChannelItemZapButton.svelte"
import ChannelItemEmojiButton from "@app/components/ChannelItemEmojiButton.svelte"
import ChannelItemMenuButton from "@app/components/ChannelItemMenuButton.svelte"
import ChannelItemMenuMobile from "@app/components/ChannelItemMenuMobile.svelte"
import ChannelItemContent from "@app/components/ChannelItemContent.svelte"
import RoomItemZapButton from "@app/components/RoomItemZapButton.svelte"
import RoomItemEmojiButton from "@app/components/RoomItemEmojiButton.svelte"
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
import RoomItemContent from "@app/components/RoomItemContent.svelte"
import {colors, ENABLE_ZAPS, deriveEventsForUrl} from "@app/core/state"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {getChannelItemPath} from "@app/util/routes"
import {getRoomItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
interface Props {
@@ -53,7 +53,7 @@
onEdit,
}: Props = $props()
const path = getChannelItemPath(url, event)
const path = getRoomItemPath(url, event)
const shouldProtect = canEnforceNip70(url)
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey, [url])
@@ -65,7 +65,7 @@
const reply = () => replyTo!(event)
const edit = canEdit(event) ? () => onEdit(event) : undefined
const onTap = () => pushModal(ChannelItemMenuMobile, {url, event, reply, edit})
const onTap = () => pushModal(RoomItemMenuMobile, {url, event, reply, edit})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
@@ -105,7 +105,7 @@
</div>
{/if}
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<ChannelItemContent {url} {event} />
<RoomItemContent {url} {event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if}
@@ -142,9 +142,9 @@
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<ChannelItemZapButton {url} {event} />
<RoomItemZapButton {url} {event} />
{/if}
<ChannelItemEmojiButton {url} {event} />
<RoomItemEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon={Reply} size={4} />
@@ -155,7 +155,7 @@
<Icon icon={Pen} size={4} />
</Button>
{/if}
<ChannelItemMenuButton {url} {event} />
<RoomItemMenuButton {url} {event} />
</button>
{/if}
</TapTarget>
@@ -5,11 +5,11 @@
import {isMobile} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {getChannelItemPath} from "@app/util/routes"
import {getRoomItemPath} from "@app/util/routes"
const props: ComponentProps<typeof NoteContent> = $props()
const path = getChannelItemPath(props.url!, props.event)
const path = getRoomItemPath(props.url!, props.event)
</script>
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
@@ -5,7 +5,7 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import ChannelItemMenu from "@app/components/ChannelItemMenu.svelte"
import RoomItemMenu from "@app/components/RoomItemMenu.svelte"
const {url, event} = $props()
@@ -34,7 +34,7 @@
</Button>
<Tippy
bind:popover
component={ChannelItemMenu}
component={RoomItemMenu}
props={{url, event, onClick}}
params={{trigger: "manual", interactive: true}} />
</div>
@@ -17,7 +17,7 @@
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {ENABLE_ZAPS} from "@app/core/state"
import {publishReaction, canEnforceNip70} from "@app/core/commands"
import {getChannelItemPath} from "@app/util/routes"
import {getRoomItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
type Props = {
@@ -28,7 +28,7 @@
const {url, event, reply}: Props = $props()
const path = getChannelItemPath(url, event)
const path = getRoomItemPath(url, event)
const shouldProtect = canEnforceNip70(url)
@@ -1,21 +1,21 @@
<script lang="ts">
import cx from "classnames"
import Link from "@lib/components/Link.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {makeSpacePath} from "@app/util/routes"
type Props = {
room: string
h: string
url: string
class?: string
unstyled?: boolean
}
const {room, url, unstyled, ...props}: Props = $props()
const {h, url, unstyled, ...props}: Props = $props()
const path = makeSpacePath(url, room)
const path = makeSpacePath(url, h)
</script>
<Link href={path} class={cx(props.class, {"link-content bg-alt": !unstyled})}>
#<ChannelName {room} {url} />
#<RoomName {h} {url} />
</Link>
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import {roomsById, makeRoomId} from "@app/core/state"
const {url, h} = $props()
</script>
{$roomsById.get(makeRoomId(url, h))?.name || h}
@@ -0,0 +1,26 @@
<script lang="ts">
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {deriveRoom} from "@app/core/state"
interface Props {
url: any
h: any
}
const {url, h}: Props = $props()
const room = deriveRoom(url, h)
</script>
{#if $room.picture}
{@const src = $room.picture}
<ImageIcon {src} alt="Room icon" />
{:else}
<Icon icon={Hashtag} />
{/if}
<div class="min-w-0 overflow-hidden text-ellipsis">
<RoomName {url} {h} />
</div>
+2 -2
View File
@@ -14,7 +14,7 @@
import SpaceJoinConfirm, {confirmSpaceJoin} from "@app/components/SpaceJoinConfirm.svelte"
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
import {checkRelayAccess} from "@app/core/commands"
import {attemptRelayAccess} from "@app/core/commands"
import {deriveSocket} from "@app/core/state"
type Props = {
@@ -31,7 +31,7 @@
loading = true
try {
const message = await checkRelayAccess(url, claim)
const message = await attemptRelayAccess(url, claim)
if (message) {
return pushToast({theme: "error", message, timeout: 30_000})
+26 -6
View File
@@ -1,20 +1,25 @@
<script lang="ts">
import Login from "@assets/icons/login-3.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Compass from "@assets/icons/compass.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import SpaceCreateExternal from "@app/components/SpaceCreateExternal.svelte"
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
import {pushModal} from "@app/util/modal"
const startCreate = () => pushModal(SpaceCreateExternal)
type Props = {
hideDiscover?: boolean
}
const {hideDiscover}: Props = $props()
const startJoin = () => pushModal(SpaceInviteAccept)
</script>
<div class="column gap-4">
<div class="column gap-2">
<ModalHeader>
{#snippet title()}
<div>Add a Space</div>
@@ -23,8 +28,23 @@
<div>Spaces are places where communities come together to work, play, and hang out.</div>
{/snippet}
</ModalHeader>
{#if !hideDiscover}
<Link href="/discover">
<CardButton class="btn-primary">
{#snippet icon()}
<div><Icon icon={Compass} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Explore Spaces</div>
{/snippet}
{#snippet info()}
<div>Join create, or browse spaces</div>
{/snippet}
</CardButton>
</Link>
{/if}
<Button onclick={startJoin}>
<CardButton class="btn-primary">
<CardButton class={hideDiscover ? "btn-primary" : "btn-neutral"}>
{#snippet icon()}
<div><Icon icon={Login} size={7} /></div>
{/snippet}
@@ -36,7 +56,7 @@
{/snippet}
</CardButton>
</Button>
<Button onclick={startCreate}>
<Link href="/spaces/create">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={AddCircle} size={7} /></div>
@@ -48,5 +68,5 @@
<div>Just a few questions and you'll be on your way.</div>
{/snippet}
</CardButton>
</Button>
</Link>
</div>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util"
import {parse, renderAsHtml} from "@welshman/content"
import Button from "@lib/components/Button.svelte"
@@ -14,7 +15,7 @@
const {url, error} = $props()
const back = () => history.back()
const back = () => goto("/home")
const requestAccess = () => pushModal(SpaceAccessRequest, {url})
</script>
@@ -37,7 +38,7 @@
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
Go Home
</Button>
<Button type="submit" class="btn btn-primary">
Request Access
+4 -2
View File
@@ -8,7 +8,6 @@
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
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 SpaceVisitConfirm, {confirmSpaceVisit} from "@app/components/SpaceVisitConfirm.svelte"
@@ -49,7 +48,10 @@
</ModalHeader>
<div class="m-auto flex flex-col gap-4">
{#if loading}
<Spinner loading>Hold tight, we're checking your connection.</Spinner>
<p class="flex items-center gap-3">
<span class="loading loading-spinner"></span>
Hold tight, we're checking your connection.
</p>
{:else if error}
<p>Oops! We ran into some problems:</p>
<p class="card2 bg-alt">{error}</p>
-76
View File
@@ -1,76 +0,0 @@
<script lang="ts">
import {preventDefault} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import FireMinimalistic from "@assets/icons/fire-minimalistic.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import InfoRelay from "@app/components/InfoRelay.svelte"
import InputProfilePicture from "@app/components/InputProfilePicture.svelte"
import SpaceCreateFinish from "@app/components/SpaceCreateFinish.svelte"
import {pushModal} from "@app/util/modal"
const back = () => history.back()
const next = () => pushModal(SpaceCreateFinish)
let file: File | undefined = $state()
let name = $state("")
let relay = $state("")
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Customize your Space</div>
{/snippet}
{#snippet info()}
<div>Give people a few details to go on. You can always change this later.</div>
{/snippet}
</ModalHeader>
<div class="flex justify-center py-2">
<InputProfilePicture bind:file />
</div>
<Field>
{#snippet label()}
<p>Space Name</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={FireMinimalistic} />
<input bind:value={name} class="grow" type="text" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Relay</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Server} />
<input bind:value={relay} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>
This can be any nostr relay where you'd like to host your space.
<Button class="link" onclick={() => pushModal(InfoRelay)}>What is a relay?</Button>
</p>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary">
Next
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
@@ -1,56 +0,0 @@
<script lang="ts">
import {preventDefault} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
import {PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal"
const back = () => history.back()
const next = () => {
window.open("https://relay.tools/signup")
setTimeout(() => pushModal(SpaceInviteAccept), 300)
}
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Create a Space</div>
{/snippet}
{#snippet info()}
<div>Host your own space, for your community.</div>
{/snippet}
</ModalHeader>
<p>
<Link class="link" external href="https://relay.tools">relay.tools</Link> is a third-party service
that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other
nostr-compatible app.
</p>
<p>
Once you've created a relay of your own, come back here to link {PLATFORM_NAME} with your new relay.
</p>
<p>
Alternatively, you can
<Link external class="link" href="https://github.com/coracle-social/frith"
>run your own community relay</Link
>.
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary">
Let's go
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
@@ -1 +0,0 @@
hi
+9 -4
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import {onMount} from "svelte"
import {sleep, nthEq} from "@welshman/lib"
import {sleep} from "@welshman/lib"
import {request} from "@welshman/net"
import {displayRelayUrl, RELAY_INVITE} from "@welshman/util"
import {displayRelayUrl, getTagValue, RELAY_INVITE} from "@welshman/util"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
@@ -13,10 +13,12 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import QRCode from "@app/components/QRCode.svelte"
import {clip} from "@app/util/toast"
import {PLATFORM_URL} from "@app/core/state"
import {PLATFORM_URL, deriveRelayAuthError} from "@app/core/state"
const {url} = $props()
const authError = deriveRelayAuthError(url)
const back = () => history.back()
const copyInvite = () => clip(invite)
@@ -38,12 +40,13 @@
request({
relays: [url],
autoClose: true,
signal: AbortSignal.timeout(3000),
filters: [{kinds: [RELAY_INVITE]}],
}),
sleep(2000),
])
claim = event?.tags.find(nthEq(0, "claim"))?.[1] || ""
claim = getTagValue("claim", event?.tags || []) || ""
loading = false
})
</script>
@@ -65,6 +68,8 @@
<p class="center">
<Spinner {loading}>Requesting an invite link...</Spinner>
</p>
{:else if $authError}
<p class="center">Oops! It looks like you're not a member of this relay.</p>
{:else}
<div class="flex flex-col items-center gap-6">
<QRCode code={invite} />
+2 -20
View File
@@ -1,7 +1,5 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {tryCatch, fromPairs} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {Pool, AuthStatus} from "@welshman/net"
import {preventDefault} from "@lib/html"
import {slideAndFade} from "@lib/transition"
@@ -19,6 +17,7 @@
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
import {attemptRelayAccess} from "@app/core/commands"
import {parseInviteLink} from "@app/core/state"
type Props = {
invite: string
@@ -57,24 +56,7 @@
let loading = $state(false)
const inviteData = $derived.by(
() =>
tryCatch(() => {
const {r: relay = "", c: claim = ""} = fromPairs(Array.from(new URL(invite).searchParams))
const url = normalizeRelayUrl(relay)
if (isRelayUrl(url)) {
return {url, claim}
}
}) ||
tryCatch(() => {
const url = normalizeRelayUrl(invite)
if (isRelayUrl(url)) {
return {url, claim: ""}
}
}),
)
const inviteData = $derived(parseInviteLink(invite))
</script>
<form class="column gap-4" onsubmit={preventDefault(join)}>
+1 -1
View File
@@ -28,7 +28,7 @@
try {
await removeSpaceMembership(url)
await removeTrustedRelay(url)
goto("/")
goto("/home")
} finally {
loading = false
}
+5 -5
View File
@@ -2,7 +2,7 @@
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import RoomName from "@app/components/RoomName.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
@@ -19,8 +19,8 @@
const {url, event, showRoom, showActivity}: Props = $props()
const h = getTagValue("h", event.tags)
const path = makeThreadPath(url, event.id)
const room = getTagValue("h", event.tags)
const shouldProtect = canEnforceNip70(url)
const deleteReaction = async (event: TrustedEvent) =>
@@ -31,9 +31,9 @@
</script>
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if room && showRoom}
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<ChannelName {room} {url} />
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
+4 -4
View File
@@ -18,10 +18,10 @@
type Props = {
url: string
room?: string
h?: string
}
const {url, room}: Props = $props()
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -57,8 +57,8 @@
tags.push(PROTECTED)
}
if (room) {
tags.push(["h", room])
if (h) {
tags.push(["h", h])
}
publishThunk({
+7 -5
View File
@@ -6,7 +6,7 @@
import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte"
import ChannelLink from "@app/components/ChannelLink.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import {makeThreadPath} from "@app/util/routes"
type Props = {
@@ -17,10 +17,12 @@
const {url, event}: Props = $props()
const title = getTagValue("title", event.tags)
const room = getTagValue("h", event.tags)
const h = getTagValue("h", event.tags)
</script>
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeThreadPath(url, event.id)}>
<Link
class="col-2 card2 bg-alt w-full cursor-pointer shadow-xl"
href={makeThreadPath(url, event.id)}>
{#if title}
<div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">{title}</p>
@@ -38,8 +40,8 @@
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by
<ProfileLink pubkey={event.pubkey} {url} />
{#if room}
in <ChannelLink {url} {room} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<ThreadActions showActivity {url} {event} />
+23 -72
View File
@@ -74,6 +74,7 @@ import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
pubkey,
sign,
signer,
session,
repository,
@@ -84,7 +85,6 @@ import {
userRelaySelections,
userInboxRelaySelections,
nip44EncryptToSelf,
loadRelay,
dropSession,
tagEventForComment,
tagEventForQuote,
@@ -104,8 +104,10 @@ import {
DEFAULT_BLOSSOM_SERVERS,
userSpaceUrls,
userSettingsValues,
getSetting,
userInboxRelays,
userGroupSelections,
shouldIgnoreError,
} from "@app/core/state"
import {loadAlertStatuses} from "@app/core/requests"
import {platform, platformName, getPushInfo} from "@app/util/push"
@@ -191,11 +193,11 @@ export const removeSpaceMembership = async (url: string) => {
return publishThunk({event, relays})
}
export const addRoomMembership = async (url: string, room: string) => {
export const addRoomMembership = async (url: string, h: string) => {
const list = get(userGroupSelections) || makeList({kind: ROOMS})
const newTags = [
["r", url],
["group", room, url],
["group", h, url],
]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -203,9 +205,9 @@ export const addRoomMembership = async (url: string, room: string) => {
return publishThunk({event, relays})
}
export const removeRoomMembership = async (url: string, room: string) => {
export const removeRoomMembership = async (url: string, h: string) => {
const list = get(userGroupSelections) || makeList({kind: ROOMS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const pred = (t: string[]) => equals(["group", h, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -250,57 +252,15 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
// Relay access
export const attemptAuth = async (url: string) => {
const socket = Pool.get().get(url)
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
}
export const canEnforceNip70 = async (url: string) => {
const socket = Pool.get().get(url)
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
await socket.auth.attemptAuth(sign)
return socket.auth.status !== AuthStatus.None
}
export const checkRelayAccess = async (url: string, claim = "") => {
const socket = Pool.get().get(url)
await attemptAuth(url)
const thunk = publishJoinRequest({url, claim})
const error = await waitForThunkError(thunk)
if (error) {
const message =
socket.auth.details?.replace(/^\w+: /, "") ||
error.replace(/^\w+: /, "") ||
"join request rejected"
// If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict
if (message === "missing group (`h`) tag") return
// Ignore messages about the relay ignoring ours
if (error?.startsWith("mute: ")) return
// Ignore rejected empty claims
if (!claim && error?.includes("invite code")) return
return message
}
}
export const checkRelayProfile = async (url: string) => {
const relay = await loadRelay(url)
if (!relay) {
return "Sorry, we weren't able to find that relay."
}
}
export const checkRelayConnection = async (url: string) => {
export const attemptRelayAccess = async (url: string, claim = "") => {
const socket = Pool.get().get(url)
socket.attemptToOpen()
@@ -313,38 +273,29 @@ export const checkRelayConnection = async (url: string) => {
if (socket.status !== SocketStatus.Open) {
return `Failed to connect`
}
}
export const checkRelayAuth = async (url: string) => {
const socket = Pool.get().get(url)
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
await attemptAuth(url)
await socket.auth.attemptAuth(sign)
// Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay
if (!okStatuses.includes(socket.auth.status)) {
if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) {
if (socket.auth.details) {
return `Failed to authenticate (${socket.auth.details})`
} else {
return `Failed to authenticate (${last(socket.auth.status.split(":"))})`
}
}
}
export const attemptRelayAccess = async (url: string, claim = "") => {
const checks = [
() => checkRelayConnection(url),
() => checkRelayAccess(url, claim),
() => checkRelayAuth(url),
]
const thunk = publishJoinRequest({url, claim})
const error = await waitForThunkError(thunk)
for (const check of checks) {
const error = await check()
if (shouldIgnoreError(error)) return
if (error) {
return error
}
if (claim) {
const ignoreClaimError =
error.includes("invalid invite code size") || error.includes("failed to validate invite code")
if (!ignoreClaimError) return error?.replace(/^\w+: /, "")
}
}
@@ -555,7 +506,7 @@ export const createAlert = async (params: CreateAlertParams): Promise<CreateAler
}
// If we don't do this we'll get an event rejection
await attemptAuth(NOTIFIER_RELAY)
await Pool.get().get(NOTIFIER_RELAY).auth.attemptAuth(sign)
const thunk = await publishAlert(params as AlertParams)
const error = await waitForThunkError(thunk)
@@ -599,7 +550,7 @@ export const createDmAlert = async () => {
// Settings
export const makeSettings = async (params: Partial<SettingsValues>) => {
const json = JSON.stringify({...userSettingsValues.get(), ...params})
const json = JSON.stringify({...get(userSettingsValues), ...params})
const content = await signer.get().nip44.encrypt(pubkey.get()!, json)
const tags = [["d", SETTINGS]]
@@ -610,10 +561,10 @@ export const publishSettings = async (params: Partial<SettingsValues>) =>
publishThunk({event: await makeSettings(params), relays: Router.get().FromUser().getUrls()})
export const addTrustedRelay = async (url: string) =>
publishSettings({trusted_relays: append(url, userSettingsValues.get().trusted_relays)})
publishSettings({trusted_relays: append(url, getSetting<string[]>("trusted_relays"))})
export const removeTrustedRelay = async (url: string) =>
publishSettings({trusted_relays: remove(url, userSettingsValues.get().trusted_relays)})
publishSettings({trusted_relays: remove(url, getSetting<string[]>("trusted_relays"))})
// Join request
+4 -2
View File
@@ -248,13 +248,15 @@ export const makeCalendarFeed = ({
// Domain specific
export const loadAlerts = (pubkey: string) =>
load({
request({
autoClose: true,
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID], authors: [pubkey]}],
})
export const loadAlertStatuses = (pubkey: string) =>
load({
request({
autoClose: true,
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT_STATUS], "#p": [pubkey]}],
})
+175 -166
View File
@@ -4,6 +4,8 @@ import {get, derived, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {
on,
gt,
max,
spec,
call,
first,
@@ -17,13 +19,13 @@ import {
pushToMapKey,
shuffle,
parseJson,
fromPairs,
memoize,
addToMapKey,
identity,
groupBy,
always,
tryCatch,
fromPairs,
} from "@welshman/lib"
import type {Socket} from "@welshman/net"
import {
@@ -35,14 +37,7 @@ import {
SocketEvent,
netContext,
} from "@welshman/net"
import {
collection,
custom,
throttled,
deriveEvents,
deriveEventsMapped,
withGetter,
} from "@welshman/store"
import {collection, custom, throttled, deriveEvents, deriveEventsMapped} from "@welshman/store"
import {isKindFeed, findFeed} from "@welshman/feeds"
import {
ALERT_ANDROID,
@@ -88,7 +83,6 @@ import {
getPubkeyTagValues,
getRelaysFromList,
getRelayTagValues,
getTag,
getTagValue,
getTagValues,
isRelayUrl,
@@ -97,8 +91,18 @@ import {
readList,
RelayMode,
verifyEvent,
readRoomMeta,
makeRoomMeta,
ManagementMethod,
} from "@welshman/util"
import type {
TrustedEvent,
RelayProfile,
PublishedList,
PublishedRoomMeta,
List,
Filter,
} from "@welshman/util"
import type {TrustedEvent, RelayProfile, PublishedList, List, Filter} from "@welshman/util"
import {decrypt} from "@welshman/signer"
import {routerContext, Router} from "@welshman/router"
import {
@@ -112,6 +116,7 @@ import {
userFollows,
ensurePlaintext,
thunks,
sign,
signer,
makeOutboxLoader,
appContext,
@@ -122,6 +127,7 @@ import {
deriveRelay,
makeUserData,
makeUserLoader,
manageRelay,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
@@ -143,7 +149,7 @@ export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS)
export const PLATFORM_URL = window.location.origin
export const PLATFORM_URL = import.meta.env.VITE_PLATFORM_URL
export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS
@@ -232,31 +238,29 @@ export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
)
}
export const getUrlsForEvent = withGetter(
derived([trackerStore, thunks], ([$tracker, $thunks]) => {
const getThunksByEventId = memoize(() => {
const thunksByEventId = new Map<string, Thunk[]>()
export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thunks]) => {
const getThunksByEventId = memoize(() => {
const thunksByEventId = new Map<string, Thunk[]>()
for (const thunk of $thunks) {
pushToMapKey(thunksByEventId, thunk.event.id, thunk)
}
return thunksByEventId
})
return (id: string) => {
const urls = Array.from($tracker.getRelays(id))
for (const thunk of getThunksByEventId().get(id) || []) {
for (const url of thunk.options.relays) {
urls.push(url)
}
}
return uniq(urls)
for (const thunk of $thunks) {
pushToMapKey(thunksByEventId, thunk.event.id, thunk)
}
}),
)
return thunksByEventId
})
return (id: string) => {
const urls = Array.from($tracker.getRelays(id))
for (const thunk of getThunksByEventId().get(id) || []) {
for (const url of thunk.options.relays) {
urls.push(url)
}
}
return uniq(urls)
}
})
export const getEventsForUrl = (url: string, filters: Filter[]) => {
const ids = uniq([
@@ -310,20 +314,9 @@ if (ENABLE_ZAPS) {
REACTION_KINDS.push(ZAP_RESPONSE)
}
export const MESSAGE_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, MESSAGE]
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD]
export const MESSAGE_FILTER = {kinds: MESSAGE_KINDS}
export const COMMENT_FILTER = makeCommentFilter(MESSAGE_KINDS)
export const MEMBERSHIP_KINDS = [
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
RELAY_ADD_MEMBER,
RELAY_REMOVE_MEMBER,
]
export const MEMBERSHIP_FILTER = {kinds: MEMBERSHIP_KINDS}
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
// Settings
@@ -385,15 +378,13 @@ export const userSettings = makeUserData({
export const loadUserSettings = makeUserLoader(loadSettings)
export const userSettingsValues = withGetter(
derived(userSettings, $s => $s?.values || defaultSettings),
)
export const userSettingsValues = derived(userSettings, $s => $s?.values || defaultSettings)
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingsValues.get()[key] as T
export const getSetting = <T>(key: keyof Settings["values"]) => get(userSettingsValues)[key] as T
// Relays sending events with empty signatures that the user has to choose to trust
export const relaysPendingTrust = withGetter(writable<string[]>([]))
export const relaysPendingTrust = writable<string[]>([])
// Relays that mostly send restricted responses to requests and events
@@ -418,21 +409,19 @@ export type Alert = {
tags: string[][]
}
export const alerts = withGetter(
deriveEventsMapped<Alert>(repository, {
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const $signer = signer.get()
export const alerts = deriveEventsMapped<Alert>(repository, {
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const $signer = signer.get()
if ($signer) {
const tags = parseJson(await decrypt($signer, NOTIFIER_PUBKEY, event.content))
if ($signer) {
const tags = parseJson(await decrypt($signer, NOTIFIER_PUBKEY, event.content))
return {event, tags}
}
},
}),
)
return {event, tags}
}
},
})
export const getAlertFeed = (alert: Alert) =>
tryCatch(() => JSON.parse(getTagValue("feed", alert.tags)!))
@@ -452,21 +441,19 @@ export type AlertStatus = {
tags: string[][]
}
export const alertStatuses = withGetter(
deriveEventsMapped<AlertStatus>(repository, {
filters: [{kinds: [ALERT_STATUS]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const $signer = signer.get()
export const alertStatuses = deriveEventsMapped<AlertStatus>(repository, {
filters: [{kinds: [ALERT_STATUS]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const $signer = signer.get()
if ($signer) {
const tags = parseJson(await decrypt($signer, NOTIFIER_PUBKEY, event.content))
if ($signer) {
const tags = parseJson(await decrypt($signer, NOTIFIER_PUBKEY, event.content))
return {event, tags}
}
},
}),
)
return {event, tags}
}
},
})
export const deriveAlertStatus = (address: string) =>
derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address))
@@ -541,62 +528,53 @@ export const chatSearch = derived(chats, $chats =>
}),
)
// Channels
// Rooms
export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]})
export type Channel = {
export type Room = PublishedRoomMeta & {
id: string
url: string
room: string
name: string
event: TrustedEvent
closed: boolean
private: boolean
picture?: string
about?: string
}
export const makeChannelId = (url: string, room: string) => `${url}'${room}`
export const makeRoomId = (url: string, h: string) => `${url}'${h}`
export const splitChannelId = (id: string) => id.split("'")
export const splitRoomId = (id: string) => id.split("'")
export const hasNip29 = (relay?: RelayProfile) =>
relay?.supported_nips?.map?.(String)?.includes?.("29")
export const channels = derived(
[deriveEvents(repository, {filters: [{kinds: [ROOM_META, ROOM_DELETE]}]}), getUrlsForEvent],
([$events, $getUrlsForEvent]) => {
const result = new Map<string, Channel>()
export const roomMetas = deriveEventsMapped<PublishedRoomMeta>(repository, {
filters: [{kinds: [ROOM_META]}],
itemToEvent: item => item.event,
eventToItem: readRoomMeta,
})
for (const event of sortBy(e => e.created_at, $events)) {
for (const url of $getUrlsForEvent(event.id)) {
if (event.kind === ROOM_META) {
const meta = fromPairs(event.tags)
const room = meta.d
export const roomDeletes = deriveEvents(repository, {
filters: [{kinds: [ROOM_DELETE]}],
})
if (room) {
const id = makeChannelId(url, room)
export const rooms = derived(
[roomMetas, roomDeletes, getUrlsForEvent],
([$roomMetas, $roomDeletes, $getUrlsForEvent]) => {
const result = new Map<string, Room>()
const deletedByH = new Map<string, number>()
result.set(id, {
id,
url,
room,
event,
name: meta.name || room,
closed: Boolean(getTag("closed", event.tags)),
private: Boolean(getTag("private", event.tags)),
picture: meta.picture,
about: meta.about,
})
}
}
for (const event of $roomDeletes) {
for (const h of getTagValues("h", event.tags)) {
deletedByH.set(h, max([deletedByH.get(h), event.created_at]))
}
}
if (event.kind === ROOM_DELETE) {
for (const room of getTagValues("h", event.tags)) {
result.delete(makeChannelId(url, room))
}
}
for (const meta of $roomMetas) {
if (gt(deletedByH.get(meta.h), meta.event.created_at)) {
continue
}
for (const url of $getUrlsForEvent(meta.event.id)) {
const id = makeRoomId(url, meta.h)
result.set(id, {...meta, url, id})
}
}
@@ -604,35 +582,33 @@ export const channels = derived(
},
)
export const channelsByUrl = derived(channels, $channels => groupBy(c => c.url, $channels))
export const roomsByUrl = derived(rooms, $rooms => groupBy(c => c.url, $rooms))
export const {
indexStore: channelsById,
deriveItem: _deriveChannel,
loadItem: _loadChannel,
indexStore: roomsById,
deriveItem: _deriveRoom,
loadItem: _loadRoom,
} = collection({
name: "channels",
store: channels,
getKey: channel => channel.id,
name: "rooms",
store: rooms,
getKey: room => room.id,
load: async (id: string) => {
const [url, room] = splitChannelId(id)
const [url, h] = splitRoomId(id)
await load({
relays: [url],
filters: [{kinds: [ROOM_META], "#d": [room]}],
filters: [{kinds: [ROOM_META], "#d": [h]}],
})
},
})
export const deriveChannel = (url: string, room: string) => _deriveChannel(makeChannelId(url, room))
export const deriveRoom = (url: string, h: string) =>
derived(_deriveRoom(makeRoomId(url, h)), $meta => $meta || makeRoomMeta({h}))
export const loadChannel = (url: string, room: string) => _loadChannel(makeChannelId(url, room))
export const displayRoom = (url: string, h: string) =>
roomsById.get().get(makeRoomId(url, h))?.name || h
export const displayChannel = (url: string, room: string) =>
channelsById.get().get(makeChannelId(url, room))?.name || room
export const roomComparator = (url: string) => (room: string) =>
displayChannel(url, room).toLowerCase()
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
// User space/room selections
@@ -696,9 +672,9 @@ export const getSpaceRoomsFromGroupSelections = (
) => {
const rooms: string[] = []
for (const [_, room, relay] of getGroupTags(getListTags($groupSelections))) {
for (const [_, h, relay] of getGroupTags(getListTags($groupSelections))) {
if (url === relay) {
rooms.push(room)
rooms.push(h)
}
}
@@ -715,12 +691,12 @@ export const loadUserGroupSelections = makeUserLoader(loadGroupSelections)
export const userSpaceUrls = derived(userGroupSelections, getSpaceUrlsFromGroupSelections)
export const deriveUserRooms = (url: string) =>
derived([userGroupSelections, channelsById], ([$userGroupSelections, $channelsById]) => {
derived([userGroupSelections, roomsById], ([$userGroupSelections, $roomsById]) => {
const rooms: string[] = []
for (const room of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) {
if ($channelsById.has(makeChannelId(url, room))) {
rooms.push(room)
for (const h of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) {
if ($roomsById.has(makeRoomId(url, h))) {
rooms.push(h)
}
}
@@ -728,12 +704,12 @@ export const deriveUserRooms = (url: string) =>
})
export const deriveOtherRooms = (url: string) =>
derived([deriveUserRooms(url), channelsByUrl], ([$userRooms, $channelsByUrl]) => {
derived([deriveUserRooms(url), roomsByUrl], ([$userRooms, $roomsByUrl]) => {
const rooms: string[] = []
for (const {room} of $channelsByUrl.get(url) || []) {
if (!$userRooms.includes(room)) {
rooms.push(room)
for (const {h} of $roomsByUrl.get(url) || []) {
if (!$userRooms.includes(h)) {
rooms.push(h)
}
}
@@ -776,11 +752,11 @@ export const deriveSpaceMembers = (url: string) =>
},
)
export const deriveRoomMembers = (url: string, room: string) =>
export const deriveRoomMembers = (url: string, h: string) =>
derived(
deriveEventsForUrl(url, [
{kinds: [ROOM_MEMBERS], "#d": [room]},
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [room]},
{kinds: [ROOM_MEMBERS], "#d": [h]},
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
]),
$events => {
const membersEvent = $events.find(spec({kind: ROOM_MEMBERS}))
@@ -811,8 +787,8 @@ export const deriveRoomMembers = (url: string, room: string) =>
},
)
export const deriveRoomAdmins = (url: string, room: string) =>
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ADMINS], "#d": [room]}]), $events => {
export const deriveRoomAdmins = (url: string, h: string) =>
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ADMINS], "#d": [h]}]), $events => {
const adminsEvent = first($events)
if (adminsEvent) {
@@ -841,7 +817,7 @@ export const deriveUserSpaceMembershipStatus = (url: string) =>
const isMember = $members.includes($pubkey)
for (const event of $events) {
if (event.pubkey !== $pubkey) {
if (!getPubkeyTagValues(event.tags).includes($pubkey!)) {
continue
}
@@ -858,18 +834,18 @@ export const deriveUserSpaceMembershipStatus = (url: string) =>
},
)
export const deriveUserRoomMembershipStatus = (url: string, room: string) =>
export const deriveUserRoomMembershipStatus = (url: string, h: string) =>
derived(
[
pubkey,
deriveRoomMembers(url, room),
deriveEventsForUrl(url, [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [room]}]),
deriveRoomMembers(url, h),
deriveEventsForUrl(url, [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}]),
],
([$pubkey, $members, $events]) => {
const isMember = $members.includes($pubkey)
for (const event of $events) {
if (event.pubkey !== $pubkey) {
if (!getPubkeyTagValues(event.tags).includes($pubkey!)) {
continue
}
@@ -896,8 +872,18 @@ export const deriveUserCanCreateRoom = (url: string) =>
},
)
export const deriveUserIsRoomAdmin = (url: string, room: string) =>
derived([pubkey, deriveRoomAdmins(url, room)], ([$pubkey, $admins]) => $admins.includes($pubkey!))
export const deriveUserIsRoomAdmin = (url: string, h: string) =>
derived([pubkey, deriveRoomAdmins(url, h)], ([$pubkey, $admins]) => $admins.includes($pubkey!))
export const deriveUserIsSpaceAdmin = (url: string) => {
const store = writable(false)
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res =>
store.set(Boolean(res.result?.length)),
)
return store
}
// Other utils
@@ -988,13 +974,19 @@ export const deriveTimeout = (timeout: number) => {
return derived(store, identity)
}
export const shouldIgnoreError = (error: string) => {
const isIgnored = error.startsWith("mute: ")
const isAborted = error.includes("Signing was aborted")
const isStrictNip29Relay = error.includes("missing group (`h`) tag")
return isIgnored || isAborted || isStrictNip29Relay
}
export const deriveRelayAuthError = (url: string, claim = "") => {
const $signer = signer.get()
const socket = Pool.get().get(url)
const stripPrefix = (m: string) => m.replace(/^\w+: /, "")
// Kick off the auth process
socket.auth.attemptAuth($signer.sign)
Pool.get().get(url).auth.attemptAuth(sign)
// Attempt to join the relay
const thunk = publishThunk({
@@ -1003,8 +995,8 @@ export const deriveRelayAuthError = (url: string, claim = "") => {
})
return derived(
[relaysMostlyRestricted, deriveSocket(url)],
([$relaysMostlyRestricted, $socket]) => {
[thunk, relaysMostlyRestricted, deriveSocket(url)],
([$thunk, $relaysMostlyRestricted, $socket]) => {
if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) {
return stripPrefix($socket.auth.details)
}
@@ -1013,17 +1005,34 @@ export const deriveRelayAuthError = (url: string, claim = "") => {
return stripPrefix($relaysMostlyRestricted[url])
}
const error = getThunkError(thunk)
const error = getThunkError($thunk)
if (error) {
const isIgnored = error.startsWith("mute: ")
const isEmptyInvite = !claim && error.includes("invite code")
const isStrictNip29Relay = error.includes("missing group (`h`) tag")
if (!isStrictNip29Relay && !isIgnored && !isEmptyInvite && !isStrictNip29Relay) {
if (!shouldIgnoreError(error) && !isEmptyInvite) {
return stripPrefix(error) || "join request rejected"
}
}
},
)
}
export type InviteData = {url: string; claim: string}
export const parseInviteLink = (invite: string): InviteData | undefined =>
tryCatch(() => {
const {r: relay = "", c: claim = ""} = fromPairs(Array.from(new URL(invite).searchParams))
const url = normalizeRelayUrl(relay)
if (isRelayUrl(url)) {
return {url, claim}
}
}) ||
tryCatch(() => {
const url = normalizeRelayUrl(invite)
if (isRelayUrl(url)) {
return {url, claim: ""}
}
})
+221 -126
View File
@@ -1,23 +1,12 @@
import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store"
import {derived, get} from "svelte/store"
import {
partition,
call,
sortBy,
assoc,
chunk,
sleep,
now,
identity,
WEEK,
MONTH,
ago,
} from "@welshman/lib"
import {partition, call, sortBy, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {
getListTags,
getRelayTagValues,
WRAP,
MESSAGE,
ROOM_META,
ROOM_DELETE,
ROOM_ADMINS,
@@ -26,7 +15,10 @@ import {
ROOM_REMOVE_MEMBER,
ROOM_CREATE_PERMISSION,
RELAY_MEMBERS,
RELAY_ADD_MEMBER,
RELAY_REMOVE_MEMBER,
isSignedEvent,
unionFilters,
} from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util"
import {request, load, pull} from "@welshman/net"
@@ -45,19 +37,23 @@ import {
repository,
shouldUnwrap,
hasNegentropy,
relaysByUrl,
} from "@welshman/app"
import {
MESSAGE_FILTER,
COMMENT_FILTER,
MEMBERSHIP_FILTER,
MESSAGE_KINDS,
CONTENT_KINDS,
INDEXER_RELAYS,
REACTION_KINDS,
loadSettings,
loadGroupSelections,
userSpaceUrls,
userGroupSelections,
bootstrapPubkeys,
decodeRelay,
getUrlsForEvent,
hasNip29,
getSpaceUrlsFromGroupSelections,
getSpaceRoomsFromGroupSelections,
makeCommentFilter,
} from "@app/core/state"
import {loadAlerts, loadAlertStatuses} from "@app/core/requests"
import {hasBlossomSupport} from "@app/core/commands"
@@ -70,7 +66,7 @@ type PullOpts = {
signal: AbortSignal
}
const pullConservatively = ({relays, filters, signal}: PullOpts) => {
const pullWithFallback = ({relays, filters, signal}: PullOpts) => {
const $getUrlsForEvent = get(getUrlsForEvent)
const [smart, dumb] = partition(hasNegentropy, relays)
const events = repository.query(filters, {shouldSort: false}).filter(isSignedEvent)
@@ -91,6 +87,20 @@ const pullConservatively = ({relays, filters, signal}: PullOpts) => {
return Promise.all(promises)
}
const pullAndListen = ({relays, filters, signal}: PullOpts) => {
pullWithFallback({
relays,
signal,
filters: filters.map(f => ({limit: 100, ...f})),
})
request({
relays,
signal,
filters: unionFilters(filters).map(assoc("limit", 0)),
})
}
// Relays
const syncRelays = () => {
@@ -121,10 +131,80 @@ const syncRelays = () => {
// User data
const syncUserSpaceMembership = (url: string) => {
const $pubkey = pubkey.get()
const controller = new AbortController()
if ($pubkey) {
pullAndListen({
relays: [url],
signal: controller.signal,
filters: [
{
kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, ROOM_CREATE_PERMISSION],
"#p": [$pubkey],
},
],
})
}
return () => controller.abort()
}
const syncUserRoomMembership = (url: string, h: string) => {
const $pubkey = pubkey.get()
const controller = new AbortController()
if ($pubkey) {
pullAndListen({
relays: [url],
signal: controller.signal,
filters: [
{
kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
"#p": [$pubkey],
"#h": [h],
},
],
})
}
return () => controller.abort()
}
const syncUserData = () => {
const unsubscribePubkey = pubkey.subscribe($pubkey => {
const unsubscribersByKey = new Map<string, Unsubscriber>()
const unsubscribeGroupSelections = userGroupSelections.subscribe($l => {
const $pubkey = pubkey.get()
if ($pubkey) {
loadRelaySelections($pubkey)
const keys = new Set<string>()
for (const url of getSpaceUrlsFromGroupSelections($l)) {
if (!unsubscribersByKey.has(url)) {
unsubscribersByKey.set(url, syncUserSpaceMembership(url))
}
keys.add(url)
for (const h of getSpaceRoomsFromGroupSelections(url, $l)) {
const key = `${url}'${h}`
if (!unsubscribersByKey.has(key)) {
unsubscribersByKey.set(key, syncUserRoomMembership(url, h))
}
keys.add(key)
}
}
for (const [key, unsubscribe] of unsubscribersByKey.entries()) {
if (!keys.has(key)) {
unsubscribersByKey.delete(key)
unsubscribe()
}
}
}
})
@@ -148,154 +228,175 @@ const syncUserData = () => {
// This isn't urgent, avoid clogging other stuff up
await sleep(1000)
for (const pk of pubkeys) {
loadRelaySelections(pk).then(() => {
loadGroupSelections(pk)
loadProfile(pk)
loadFollows(pk)
loadMutes(pk)
})
}
await Promise.all(
pubkeys.map(async pk => {
await loadRelaySelections(pk)
await loadGroupSelections(pk)
await loadProfile(pk)
await loadFollows(pk)
await loadMutes(pk)
}),
)
}
})
const unsubscribePubkey = pubkey.subscribe($pubkey => {
if ($pubkey) {
loadRelaySelections($pubkey)
}
})
return () => {
unsubscribePubkey()
unsubscribersByKey.forEach(call)
unsubscribeGroupSelections()
unsubscribeSelections()
unsubscribeFollows()
unsubscribePubkey()
}
}
// Memberships
const syncMembership = (url: string) => {
const controller = new AbortController()
const relayFilter = {kinds: [RELAY_MEMBERS, ROOM_CREATE_PERMISSION]}
const roomsFilter = {kinds: [ROOM_ADMINS, ROOM_MEMBERS, ROOM_META, ROOM_DELETE]}
// Load group metadata and member lists
pullConservatively({
relays: [url],
signal: controller.signal,
filters: [relayFilter, roomsFilter],
})
// Load historical data from up to a month ago for quick page loading
pullConservatively({
relays: [url],
signal: controller.signal,
filters: [MESSAGE_FILTER, COMMENT_FILTER, MEMBERSHIP_FILTER].map(assoc("since", ago(MONTH))),
})
// Listen for new events
request({
relays: [url],
signal: controller.signal,
filters: [relayFilter, roomsFilter, MESSAGE_FILTER, COMMENT_FILTER, MEMBERSHIP_FILTER].map(
assoc("since", now()),
),
})
return () => controller.abort()
}
const syncMemberships = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>()
const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => {
// stop syncing removed spaces
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!urls.includes(url)) {
unsubscribersByUrl.delete(url)
unsubscribe()
}
}
// Start syncing newly added spaces
for (const url of urls) {
if (!unsubscribersByUrl.has(url)) {
unsubscribersByUrl.set(url, syncMembership(url))
}
}
})
return () => {
Array.from(unsubscribersByUrl.values()).forEach(call)
unsubscribeSpaceUrls()
}
}
// Sync extra stuff for the current space
// Spaces
const syncSpace = (url: string) => {
const $pubkey = pubkey.get()
const controller = new AbortController()
// Load all membership changes for the current user
if ($pubkey) {
pullConservatively({
relays: [url],
signal: controller.signal,
filters: [
{
kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
"#p": [$pubkey],
},
],
})
}
// Listen actively for all current membership changes, reports, reactions, zaps, etc
request({
pullAndListen({
relays: [url],
signal: controller.signal,
filters: [
{
kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, ...REACTION_KINDS],
since: now(),
},
{kinds: [RELAY_MEMBERS]},
{kinds: [ROOM_META, ROOM_DELETE]},
{kinds: [ROOM_ADMINS, ROOM_MEMBERS]},
{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]},
...MESSAGE_KINDS.map(kind => ({kinds: [kind]})),
makeCommentFilter(CONTENT_KINDS),
],
})
return () => controller.abort()
}
const syncCurrentSpace = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>()
const syncSpaces = () => {
const membershipUnsubscribersByUrl = new Map<string, Unsubscriber>()
const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => {
// stop syncing removed spaces
for (const [url, unsubscribe] of membershipUnsubscribersByUrl.entries()) {
if (!urls.includes(url)) {
membershipUnsubscribersByUrl.delete(url)
unsubscribe()
}
}
// Start syncing newly added spaces
for (const url of urls) {
if (!membershipUnsubscribersByUrl.has(url)) {
membershipUnsubscribersByUrl.set(url, syncSpace(url))
}
}
})
const pageUnsubscribersByUrl = new Map<string, Unsubscriber>()
// Sync the space the user is currently visiting
const unsubscribePage = page.subscribe($page => {
if ($page.params.relay) {
const url = decodeRelay($page.params.relay)
if (!unsubscribersByUrl.has(url)) {
unsubscribersByUrl.set(url, syncSpace(url))
// Don't subscribe twice if the user is a member
if (!pageUnsubscribersByUrl.has(url) && !get(userSpaceUrls).includes(url)) {
pageUnsubscribersByUrl.set(url, syncSpace(url))
}
for (const [oldUrl, unsubscribe] of unsubscribersByUrl.entries()) {
// Clean up old subscriptions
for (const [oldUrl, unsubscribe] of pageUnsubscribersByUrl.entries()) {
if (url !== oldUrl) {
unsubscribersByUrl.delete(oldUrl)
pageUnsubscribersByUrl.delete(oldUrl)
unsubscribe()
}
}
} else {
Array.from(unsubscribersByUrl.values()).forEach(call)
Array.from(pageUnsubscribersByUrl.values()).forEach(call)
}
})
return () => {
Array.from(unsubscribersByUrl.values()).forEach(call)
Array.from(membershipUnsubscribersByUrl.values()).forEach(call)
Array.from(pageUnsubscribersByUrl.values()).forEach(call)
unsubscribeSpaceUrls()
unsubscribePage()
}
}
// Chat
const syncRoom = (url: string, h: string) => {
const controller = new AbortController()
pullAndListen({
relays: [url],
signal: controller.signal,
filters: [
{kinds: [ROOM_ADMINS, ROOM_MEMBERS], "#d": [h]},
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
{kinds: [MESSAGE], "#h": [h]},
],
})
return () => controller.abort()
}
const syncRooms = () => {
const unsubscribersByKey = new Map<string, Unsubscriber>()
const unsubscribeSpaceUrls = derived([userGroupSelections, relaysByUrl], identity).subscribe(
([$l, $relaysByUrl]) => {
const keys = new Set<string>()
const newUnsubscribersByKey = new Map<string, Unsubscriber>()
// Add new subscriptions, depending on whether nip 29 is supported
for (const url of getRelayTagValues(getListTags($l))) {
if (hasNip29($relaysByUrl.get(url))) {
for (const h of getSpaceRoomsFromGroupSelections(url, $l)) {
const key = `${url}'${h}`
if (!unsubscribersByKey.has(key)) {
newUnsubscribersByKey.set(key, syncRoom(url, h))
}
keys.add(key)
}
}
}
// Stop syncing removed selections
for (const [key, unsubscribe] of unsubscribersByKey.entries()) {
if (!keys.has(key)) {
unsubscribersByKey.delete(key)
unsubscribe()
}
}
// Start syncing newly added spaces
for (const [key, unsubscriber] of newUnsubscribersByKey.entries()) {
unsubscribersByKey.set(key, unsubscriber)
}
},
)
return () => {
Array.from(unsubscribersByKey.values()).forEach(call)
unsubscribeSpaceUrls()
}
}
// DMs
const syncDMRelay = (url: string, pubkey: string) => {
const controller = new AbortController()
// Load historical data
pullConservatively({
pullWithFallback({
relays: [url],
signal: controller.signal,
filters: [{kinds: [WRAP], "#p": [pubkey], until: ago(WEEK, 2)}],
@@ -378,13 +479,7 @@ const syncDMs = () => {
// Merge all synchronization functions
export const syncApplicationData = () => {
const unsubscribers = [
syncRelays(),
syncUserData(),
syncMemberships(),
syncCurrentSpace(),
syncDMs(),
]
const unsubscribers = [syncRelays(), syncUserData(), syncSpaces(), syncRooms(), syncDMs()]
return () => unsubscribers.forEach(call)
}
+1
View File
@@ -6,6 +6,7 @@ import {page} from "$app/stores"
export type ModalOptions = {
drawer?: boolean
noEscape?: boolean
fullscreen?: boolean
replaceState?: boolean
path?: string
+3 -5
View File
@@ -171,11 +171,9 @@ export const notifications = derived(
}
if (hasNip29($relaysByUrl.get(url))) {
for (const room of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) {
const roomPath = makeRoomPath(url, room)
const latestEvent = allMessages.find(
e => $getUrlsForEvent(e.id).includes(url) && e.tags.find(spec(["h", room])),
)
for (const h of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) {
const roomPath = makeRoomPath(url, h)
const latestEvent = messages.find(e => e.tags.some(spec(["h", h])))
if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePathMobile)
+30 -17
View File
@@ -1,5 +1,4 @@
import {on, call, dissoc, assoc, uniq} from "@welshman/lib"
import type {StampedEvent} from "@welshman/util"
import {on, always, call, dissoc, assoc, uniq} from "@welshman/lib"
import type {Socket, RelayMessage, ClientMessage} from "@welshman/net"
import {
makeSocketPolicyAuth,
@@ -7,11 +6,14 @@ import {
isRelayEvent,
isRelayOk,
isRelayClosed,
isRelayNegErr,
isClientReq,
isClientEvent,
isClientClose,
isClientNegOpen,
isClientNegClose,
} from "@welshman/net"
import {signer} from "@welshman/app"
import {sign} from "@welshman/app"
import {
userSettingsValues,
getSetting,
@@ -19,10 +21,7 @@ import {
relaysMostlyRestricted,
} from "@app/core/state"
export const authPolicy = makeSocketPolicyAuth({
sign: (event: StampedEvent) => signer.get()?.sign(event),
shouldAuth: (socket: Socket) => true,
})
export const authPolicy = makeSocketPolicyAuth({sign, shouldAuth: always(true)})
export const trustPolicy = (socket: Socket) => {
const buffer: RelayMessage[] = []
@@ -76,20 +75,32 @@ export const mostlyRestrictedPolicy = (socket: Socket) => {
if (pending.has(id)) {
pending.delete(id)
if (!ok && details.startsWith("restricted: ")) {
restricted++
error = details
updateStatus()
if (!ok) {
if (details.startsWith("auth-required: ")) {
total--
updateStatus()
}
if (details.startsWith("restricted: ")) {
restricted++
error = details
updateStatus()
}
}
}
}
if (isRelayClosed(message)) {
if (isRelayClosed(message) || isRelayNegErr(message)) {
const [_, id, details = ""] = message
if (pending.has(id)) {
pending.delete(id)
if (details.startsWith("auth-required: ")) {
total--
updateStatus()
}
if (details.startsWith("restricted: ")) {
restricted++
error = details
@@ -99,10 +110,12 @@ export const mostlyRestrictedPolicy = (socket: Socket) => {
}
}),
on(socket, SocketEvent.Send, (message: ClientMessage) => {
if (isClientReq(message)) {
total++
pending.add(message[1])
updateStatus()
if (isClientReq(message) || isClientNegOpen(message)) {
if (!pending.has(message[1])) {
total++
pending.add(message[1])
updateStatus()
}
}
if (isClientEvent(message)) {
@@ -111,7 +124,7 @@ export const mostlyRestrictedPolicy = (socket: Socket) => {
updateStatus()
}
if (isClientClose(message)) {
if (isClientClose(message) || isClientNegClose(message)) {
pending.delete(message[1])
}
}),
+19 -14
View File
@@ -4,7 +4,7 @@ import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {nthEq, sleep} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {tracker, relaysByUrl} from "@welshman/app"
import {tracker, loadRelay} from "@welshman/app"
import {scrollToEvent} from "@lib/html"
import {identity} from "@welshman/lib"
import {
@@ -26,6 +26,7 @@ import {
hasNip29,
ROOM,
} from "@app/core/state"
import {lastPageBySpaceUrl} from "@app/util/history"
export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) => {
let path = `/spaces/${encodeRelay(url)}`
@@ -37,22 +38,26 @@ export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) =>
.filter(identity)
.map(s => encodeURIComponent(s as string))
.join("/")
} else {
const relay = relaysByUrl.get().get(url)
if (hasNip29(relay)) {
path += "/recent"
} else {
path += "/chat"
}
}
return path
}
export const goToSpace = async (url: string) => {
const prevPath = lastPageBySpaceUrl.get(encodeRelay(url))
if (prevPath) {
goto(prevPath)
} else if (hasNip29(await loadRelay(url))) {
goto(makeSpacePath(url, "recent"))
} else {
goto(makeSpacePath("chat"))
}
}
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
export const makeRoomPath = (url: string, room: string) => `/spaces/${encodeRelay(url)}/${room}`
export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(url)}/${h}`
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
@@ -103,7 +108,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)])
}
const room = getTagValue(ROOM, event.tags)
const h = getTagValue(ROOM, event.tags)
if (urls.length > 0) {
const url = urls[0]
@@ -121,7 +126,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
}
if (event.kind === MESSAGE) {
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
return h ? makeRoomPath(url, h) : makeSpacePath(url, "chat")
}
const kind = event.tags.find(nthEq(0, "K"))?.[1]
@@ -141,7 +146,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
}
if (parseInt(kind) === MESSAGE) {
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
return h ? makeRoomPath(url, h) : makeSpacePath(url, "chat")
}
}
}
@@ -149,7 +154,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
}
export const getChannelItemPath = (url: string, event: TrustedEvent) => {
export const getRoomItemPath = (url: string, event: TrustedEvent) => {
switch (event.kind) {
case THREAD:
return makeThreadPath(url, event.id)
+14 -5
View File
@@ -25,7 +25,9 @@ import {
ROOM_ADD_MEMBER,
ROOM_CREATE_PERMISSION,
ROOM_MEMBERS,
ROOM_ADMINS,
ROOM_META,
ROOM_DELETE,
ROOM_REMOVE_MEMBER,
ROOMS,
THREAD,
@@ -48,6 +50,7 @@ import {
wrapManager,
} from "@welshman/app"
import {Collection} from "@lib/storage"
import {isMobile} from "@lib/html"
const syncEvents = async () => {
const collection = new Collection<TrustedEvent>({table: "events", getId: prop("id")})
@@ -75,6 +78,8 @@ const syncEvents = async () => {
const spaceKinds = [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS, RELAY_JOIN, RELAY_LEAVE]
const roomKinds = [
ROOM_META,
ROOM_DELETE,
ROOM_ADMINS,
ROOM_MEMBERS,
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
@@ -87,7 +92,7 @@ const syncEvents = async () => {
if (alertKinds.includes(event.kind)) return 8
if (spaceKinds.includes(event.kind)) return 7
if (roomKinds.includes(event.kind)) return 6
if (contentKinds.includes(event.kind)) return 5
if (!isMobile && contentKinds.includes(event.kind)) return 5
return 0
}
@@ -236,17 +241,21 @@ const syncWrapManager = async () => {
}
export const syncDataStores = async () => {
const unsubscribers = await Promise.all([
const promises = [
syncEvents(),
syncTracker(),
syncRelays(),
syncRelayStats(),
syncHandles(),
syncZappers(),
syncFreshness(),
syncPlaintext(),
syncWrapManager(),
])
]
if (!isMobile) {
promises.push(syncFreshness(), syncRelayStats())
}
const unsubscribers = await Promise.all(promises)
return () => unsubscribers.forEach(call)
}
+7 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {noop} from "@welshman/lib"
import {fade, fly} from "@lib/transition"
@@ -12,7 +13,11 @@
const extraClass = $derived(
!fullscreen &&
"card2 bg-alt max-h-[90vh] w-[90vw] overflow-auto text-base-content sm:w-[520px] shadow-xl",
cx(
"bg-alt text-base-content overflow-auto text-base-content shadow-xl",
"px-4 py-6 bottom-0 left-0 right-0 top-20 rounded-t-box absolute",
"sm:p-6 sm:max-h-[90vh] sm:w-[520px] sm:rounded-box sm:relative sm:top-0",
),
)
</script>
@@ -23,7 +28,7 @@
transition:fade={{duration: 300}}
onclick={onClose}>
</button>
<div class="scroll-container relative {extraClass}" transition:fly={{duration: 300}}>
<div class="scroll-container {extraClass}" transition:fly={{duration: 300}}>
{@render children?.()}
</div>
</div>
+1 -1
View File
@@ -16,7 +16,7 @@
<div class="col-span-2 flex items-center gap-2">
{@render props.input?.()}
</div>
<p class="flex-end text-sm md:col-span-3">
<p class="flex-end text-sm opacity-70 md:col-span-3">
{#if props.info}
{@render props.info?.()}
{/if}
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import Icon from "@lib/components/Icon.svelte"
type Props = {
src: string
alt: string
}
const {src, alt}: Props = $props()
</script>
{#if src.includes("image/svg") || src.endsWith(".svg")}
<Icon icon={src} />
{:else}
<img {src} {alt} class="h-5 w-5 rounded-lg object-cover" />
{/if}
+1 -1
View File
@@ -9,5 +9,5 @@
<div class="column m-auto max-w-xs gap-2 py-4">
<h1 class="heading">{@render title?.()}</h1>
<p class="text-center">{@render info?.()}</p>
<p class="text-center text-sm opacity-75">{@render info?.()}</p>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
const {...props}: Props = $props()
</script>
<div data-component="PageBar" class="cw top-sai fixed z-feature p-2">
<div data-component="PageBar" class="cw top-sai fixed z-feature p-2 {props.class}">
<div
class="flex min-h-12 items-center justify-between gap-4 rounded-xl bg-base-100 px-4 shadow-xl">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
+1 -1
View File
@@ -14,6 +14,6 @@
{...props}
bind:this={element}
data-component="PageContent"
class="scroll-container cw md:bottom-sai fixed bottom-[calc(var(--saib)+3.5rem)] top-[calc(var(--sait)+3rem)] overflow-y-auto overflow-x-hidden {props.class}">
class="scroll-container cw md:bottom-sai fixed bottom-[calc(var(--saib)+3.5rem)] top-[calc(var(--sait)+3rem)] z-feature overflow-y-auto overflow-x-hidden {props.class}">
{@render children?.()}
</div>
+3 -3
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {makeSpacePath} from "@app/util/routes"
import {goToSpace} from "@app/util/routes"
import {PLATFORM_RELAYS} from "@app/core/state"
onMount(() => {
onMount(async () => {
if (PLATFORM_RELAYS.length > 0) {
goto(makeSpacePath(PLATFORM_RELAYS[0]))
goToSpace(PLATFORM_RELAYS[0])
} else {
goto("/home")
}
+26 -14
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import {onMount} from "svelte"
import {debounce} from "throttle-debounce"
import {dec, tryCatch} from "@welshman/lib"
import {dec} from "@welshman/lib"
import type {RelayProfile} from "@welshman/util"
import {ROOMS, normalizeRelayUrl, isRelayUrl} from "@welshman/util"
import {ROOMS} from "@welshman/util"
import {Router} from "@welshman/router"
import {load} from "@welshman/net"
import {relays, createSearch, loadRelay} from "@welshman/app"
@@ -28,12 +28,11 @@
loadGroupSelections,
getSpaceUrlsFromGroupSelections,
groupSelectionsPubkeysByUrl,
parseInviteLink,
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
const openMenu = () => pushModal(SpaceAdd)
const termUrl = $derived(tryCatch(() => normalizeRelayUrl(term)) || "")
const openMenu = () => pushModal(SpaceAdd, {hideDiscover: true})
const toggleScanner = () => {
showScanner = !showScanner
@@ -60,7 +59,7 @@
const relaySearch = $derived(
createSearch(
$relays.filter(r => $groupSelectionsPubkeysByUrl.has(r.url) && r.url !== termUrl),
$relays.filter(r => $groupSelectionsPubkeysByUrl.has(r.url) && r.url !== inviteData?.url),
{
getValue: (relay: RelayProfile) => relay.url,
sortFn: ({score, item}) => {
@@ -78,13 +77,21 @@
),
)
const openSpace = (url: string) => pushModal(SpaceCheck, {url})
const openSpace = (url: string, claim = "") => {
if (claim) {
pushModal(SpaceInviteAccept, {invite: term})
} else {
pushModal(SpaceCheck, {url})
}
}
let term = $state("")
let limit = $state(20)
let showScanner = $state(false)
let element: Element
const inviteData = $derived(parseInviteLink(term))
onMount(() => {
const scroller = createScroller({
element,
@@ -114,13 +121,18 @@
<div class="row-2 min-w-0 flex-grow items-center">
<label class="input input-bordered flex flex-grow items-center gap-2">
<Icon icon={Magnifier} />
<input bind:value={term} class="grow" type="text" placeholder="Search for spaces..." />
<input
bind:value={term}
class="grow"
type="text"
placeholder="Search for spaces or paste invite link..." />
<Button onclick={toggleScanner} class="center">
<Icon icon={QrCode} />
</Button>
</label>
<Button class="btn btn-primary" onclick={openMenu}>
<Icon icon={AddCircle} />
<span class="hidden sm:inline">Add Space</span>
</Button>
</div>
{#if showScanner}
@@ -130,15 +142,15 @@
{/snippet}
{#snippet content()}
<div class="col-2 scroll-container" bind:this={element}>
{#key termUrl}
{#if isRelayUrl(termUrl)}
{#if inviteData}
{#key inviteData.url}
<Button
class="card2 bg-alt shadow-xl transition-all hover:shadow-2xl hover:dark:brightness-[1.1]"
onclick={() => openSpace(termUrl)}>
<RelaySummary url={termUrl} />
onclick={() => openSpace(inviteData.url, inviteData.claim)}>
<RelaySummary url={inviteData.url} />
</Button>
{/if}
{/key}
{/key}
{/if}
{#each relaySearch.searchOptions(term).slice(0, limit) as relay (relay.url)}
<Button
class="card2 bg-alt shadow-xl transition-all hover:shadow-2xl hover:dark:brightness-[1.1]"
+3 -3
View File
@@ -12,16 +12,16 @@
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import {pushModal} from "@app/util/modal"
import {makeSpacePath} from "@app/util/routes"
import {goToSpace} from "@app/util/routes"
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/core/state"
const addSpace = () => pushModal(SpaceAdd)
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
onMount(() => {
onMount(async () => {
if (PLATFORM_RELAYS.length > 0) {
goto(makeSpacePath(PLATFORM_RELAYS[0]))
goToSpace(PLATFORM_RELAYS[0])
}
})
</script>
+1 -1
View File
@@ -9,7 +9,7 @@
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import Settings from "@assets/icons/settings-minimalistic.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import AltArrowUp from "@assets/icons/alt-arrow-up.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Page from "@lib/components/Page.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
import {pushModal} from "@app/util/modal"
const addSpace = () => pushModal(SpaceAdd)
</script>
<Page class="cw-full">
<PageBar class="cw-full">
{#snippet icon()}
<div class="center">
<Icon icon={SettingsMinimalistic} />
</div>
{/snippet}
{#snippet title()}
<strong>Your Spaces</strong>
{/snippet}
{#snippet action()}
{#if $userSpaceUrls.length > 0 && PLATFORM_RELAYS.length === 0}
<Button class="btn btn-primary btn-sm" onclick={addSpace}>
<Icon icon={AddCircle} />
Add Space
</Button>
{/if}
{/snippet}
</PageBar>
<PageContent class="cw-full flex flex-col gap-2 p-2 pt-4">
{#each PLATFORM_RELAYS as url (url)}
<MenuSpacesItem {url} />
{:else}
{#each $userSpaceUrls as url (url)}
<MenuSpacesItem {url} />
{:else}
<div class="flex flex-col gap-8 items-center py-20">
<p>You haven't added any spaces yet!</p>
<Button class="btn btn-primary" onclick={addSpace}>
<Icon icon={AddCircle} />
Add a Space
</Button>
</div>
{/each}
{/each}
</PageContent>
</Page>
+7 -8
View File
@@ -3,7 +3,6 @@
import {page} from "$app/stores"
import {once} from "@welshman/lib"
import Page from "@lib/components/Page.svelte"
import Dialog from "@lib/components/Dialog.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import MenuSpace from "@app/components/MenuSpace.svelte"
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
@@ -23,7 +22,11 @@
const authError = deriveRelayAuthError(url)
const showAuthError = once(() => pushModal(SpaceAuthError, {url, error: $authError}))
const showAuthError = once(() =>
pushModal(SpaceAuthError, {url, error: $authError}, {noEscape: true}),
)
const showPendingTrust = once(() => pushModal(SpaceTrustRelay, {url}, {noEscape: true}))
// We have to watch this one, since on mobile the badge will be visible when active
$effect(() => {
@@ -36,6 +39,8 @@
$effect(() => {
if ($authError) {
showAuthError()
} else if ($relaysPendingTrust.includes(url)) {
showPendingTrust()
}
})
</script>
@@ -48,9 +53,3 @@
{@render children?.()}
{/key}
</Page>
{#if $relaysPendingTrust.includes(url)}
<Dialog>
<SpaceTrustRelay {url} />
</Dialog>
{/if}
@@ -3,7 +3,6 @@
import {readable} from "svelte/store"
import {onMount, onDestroy} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import type {Readable} from "svelte/store"
import type {MakeNonOptional} from "@welshman/lib"
import {now, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
@@ -15,20 +14,12 @@
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
} from "@welshman/util"
import {
pubkey,
publishThunk,
waitForThunkError,
deleteRoom,
joinRoom,
leaveRoom,
repository,
} from "@welshman/app"
import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition"
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Logout2 from "@assets/icons/logout-3.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
@@ -38,22 +29,22 @@
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import ChannelItem from "@app/components/ChannelItem.svelte"
import ChannelItemAddMember from "@src/app/components/ChannelItemAddMember.svelte"
import ChannelItemRemoveMember from "@src/app/components/ChannelItemRemoveMember.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import RoomEdit from "@app/components/RoomEdit.svelte"
import RoomName from "@app/components/RoomName.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import {
deriveUserRooms,
userSettingsValues,
decodeRelay,
deriveUserRoomMembershipStatus,
deriveChannel,
deriveRoom,
MembershipStatus,
PROTECTED,
MESSAGE_KINDS,
@@ -71,28 +62,27 @@
import {popKey} from "@lib/implicit"
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
import {makeSpacePath} from "@app/util/routes"
const {room, relay} = $page.params as MakeNonOptional<typeof $page.params>
const {h, relay} = $page.params as MakeNonOptional<typeof $page.params>
const mounted = now()
const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay(relay)
const channel = deriveChannel(url, room)
const room = deriveRoom(url, h)
const shouldProtect = canEnforceNip70(url)
const userRooms = deriveUserRooms(url)
const userIsAdmin = deriveUserIsRoomAdmin(url, room)
const isFavorite = $derived($userRooms.includes(room))
const membershipStatus = deriveUserRoomMembershipStatus(url, room)
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
const isFavorite = $derived($userRooms.includes(h))
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const addFavorite = () => addRoomMembership(url, room)
const addFavorite = () => addRoomMembership(url, h)
const removeFavorite = () => removeRoomMembership(url, room)
const removeFavorite = () => removeRoomMembership(url, h)
const join = async () => {
joining = true
try {
const message = await waitForThunkError(joinRoom(url, makeRoomMeta({id: room})))
const message = await waitForThunkError(joinRoom(url, makeRoomMeta({h})))
if (message && !message.startsWith("duplicate:")) {
return pushToast({theme: "error", message})
@@ -108,7 +98,7 @@
const leave = async () => {
leaving = true
try {
const message = await waitForThunkError(leaveRoom(url, makeRoomMeta({id: room})))
const message = await waitForThunkError(leaveRoom(url, makeRoomMeta({h})))
if (message && !message.startsWith("duplicate:")) {
pushToast({theme: "error", message})
@@ -136,7 +126,7 @@
}
const onSubmit = async ({content, tags}: EventContent) => {
tags.push(["h", room])
tags.push(["h", h])
if (await shouldProtect) {
tags.push(PROTECTED)
@@ -214,7 +204,7 @@
let showScrollButton = $state(false)
let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([]))
let compose: ChannelCompose | undefined = $state()
let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
const elements = $derived.by(() => {
@@ -280,7 +270,7 @@
const feed = makeFeed({
url,
element: element!,
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [room]}],
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
onExhausted: () => {
loadingEvents = false
},
@@ -290,6 +280,12 @@
cleanup = feed.cleanup
}
const onEscape = () => {
clearParent()
clearShare()
eventToEdit = undefined
}
const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $pubkey && event.created_at >= ago(5, MINUTE)
@@ -307,23 +303,7 @@
}
}
const startDelete = () =>
pushModal(Confirm, {
title: "Are you sure you want to delete this room?",
message:
"This room will no longer be accessible to space members, and all messages posted to it will be deleted.",
confirm: async () => {
const thunk = deleteRoom(url, makeRoomMeta({id: room}))
const message = await waitForThunkError(thunk)
if (message) {
repository.removeEvent(thunk.event.id)
pushToast({theme: "error", message})
} else {
goto(makeSpacePath(url))
}
},
})
const startEdit = () => pushModal(RoomEdit, {url, h})
onMount(() => {
const observer = new ResizeObserver(() => {
@@ -360,17 +340,17 @@
{/snippet}
{#snippet title()}
<strong class="ellipsize">
<ChannelName {url} {room} />
<RoomName {url} {h} />
</strong>
{/snippet}
{#snippet action()}
<div class="row-2">
{#if $userIsAdmin || true}
{#if $userIsAdmin}
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip="Delete this room"
onclick={startDelete}>
<Icon size={4} icon={TrashBin2} />
data-tip="Edit room information"
onclick={startEdit}>
<Icon size={4} icon={Pen} />
</Button>
{:else if $membershipStatus === MembershipStatus.Initial}
<Button
@@ -412,24 +392,26 @@
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
{#if $channel?.private && $membershipStatus !== MembershipStatus.Granted}
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<div class="py-20">
<div class="card2 col-8 m-auto max-w-md items-center text-center">
<p class="row-2">You aren't currently a member of this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Join Room
</Button>
<p class="opacity-75">You aren't currently a member of this room.</p>
{#if !$room.isClosed}
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Join Room
</Button>
{/if}
{/if}
</div>
</div>
@@ -449,12 +431,12 @@
{:else}
{@const event = $state.snapshot(value as TrustedEvent)}
{#if event.kind === ROOM_ADD_MEMBER}
<ChannelItemAddMember {url} {event} />
<RoomItemAddMember {url} {event} />
{:else if event.kind === ROOM_REMOVE_MEMBER}
<ChannelItemRemoveMember {url} {event} />
<RoomItemRemoveMember {url} {event} />
{:else}
<div in:slide class:-mt-1={!showPubkey}>
<ChannelItem
<RoomItem
{url}
{event}
{replyTo}
@@ -476,44 +458,47 @@
</PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
{#if $channel?.private && $membershipStatus !== MembershipStatus.Granted}
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $channel?.closed && $membershipStatus !== MembershipStatus.Granted}
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted}
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
<p>Only members are allowed to post to this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
<p class="opacity-75">Only members are allowed to post to this room.</p>
{#if !$room.isClosed}
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{/if}
{/if}
</div>
{:else}
<div>
{#if parent}
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<ChannelComposeEdit clear={clearEventToEdit} />
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<ChannelCompose
<RoomCompose
{url}
{room}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
bind:this={compose} />
+21 -14
View File
@@ -18,12 +18,12 @@
import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelItem from "@app/components/ChannelItem.svelte"
import ChannelItemAddMember from "@src/app/components/ChannelItemAddMember.svelte"
import ChannelItemRemoveMember from "@src/app/components/ChannelItemRemoveMember.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import {userSettingsValues, decodeRelay, PROTECTED, MESSAGE_KINDS} from "@app/core/state"
import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands"
import {setChecked, checked} from "@app/util/notifications"
@@ -128,7 +128,7 @@
let showScrollButton = $state(false)
let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([]))
let compose: ChannelCompose | undefined = $state()
let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
const elements = $derived.by(() => {
@@ -188,6 +188,12 @@
return elements
})
const onEscape = () => {
clearParent()
clearShare()
eventToEdit = undefined
}
const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $pubkey && event.created_at >= ago(5, MINUTE)
@@ -274,12 +280,12 @@
{:else}
{@const event = $state.snapshot(value as TrustedEvent)}
{#if event.kind === RELAY_ADD_MEMBER}
<ChannelItemAddMember {url} {event} />
<RoomItemAddMember {url} {event} />
{:else if event.kind === RELAY_REMOVE_MEMBER}
<ChannelItemRemoveMember {url} {event} />
<RoomItemRemoveMember {url} {event} />
{:else}
<div class:-mt-1={!showPubkey}>
<ChannelItem
<RoomItem
{url}
{event}
{replyTo}
@@ -302,19 +308,20 @@
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div>
{#if parent}
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<ChannelComposeEdit clear={clearEventToEdit} />
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<ChannelCompose
<RoomCompose
{url}
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
bind:this={compose} />
@@ -21,7 +21,7 @@
const conversations = derived(messages, $messages => {
const convs = []
for (const [room, messages] of groupBy(e => getTagValue("h", e.tags), $messages).entries()) {
for (const [h, messages] of groupBy(e => getTagValue("h", e.tags), $messages).entries()) {
const avgTime = avg(overlappingPairs(messages).map(([a, b]) => a.created_at - b.created_at))
const groups: TrustedEvent[][] = []
const group: TrustedEvent[] = []
@@ -52,7 +52,7 @@
const earliest = last(events)!
const participants = uniq(events.map(msg => msg.pubkey))
convs.push({room, events, latest, earliest, participants})
convs.push({h, events, latest, earliest, participants})
}
}
@@ -96,10 +96,10 @@
{#if $messages.length > 0}
{@const events = $messages.slice(0, 1)}
{@const event = events[0]}
{@const room = getTagValue("h", event.tags)}
{@const h = getTagValue("h", event.tags)}
<ConversationCard
{h}
{url}
{room}
{events}
latest={event}
earliest={event}
@@ -110,8 +110,8 @@
</div>
{/if}
{:else}
{#each $conversations.slice(0, limit) as { room, events, latest, earliest, participants } (latest.id)}
<ConversationCard {url} {room} {events} {latest} {earliest} {participants} />
{#each $conversations.slice(0, limit) as { h, events, latest, earliest, participants } (latest.id)}
<ConversationCard {h} {url} {events} {latest} {earliest} {participants} />
{/each}
{/if}
</PageContent>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import Server from "@assets/icons/server.svg?dataurl"
import ArrowRight from "@assets/icons/arrow-right.svg?dataurl"
import HandShake from "@assets/icons/hand-shake.svg?dataurl"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte"
import PageHeader from "@lib/components/PageHeader.svelte"
import PageContent from "@lib/components/PageContent.svelte"
</script>
<Page class="cw-full">
<PageContent class="cw-full flex flex-col items-center gap-2 p-2 pt-4">
<PageHeader>
{#snippet title()}
<div>Create your own Space</div>
{/snippet}
{#snippet info()}
<p>Get started with one of our trusted partners, or learn how to host your own space.</p>
{/snippet}
</PageHeader>
<div class="grid w-full max-w-lg grid-cols-1 gap-2 lg:max-w-4xl lg:grid-cols-2">
<div class="card2 bg-alt flex flex-col gap-4">
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Icon icon={Server} />
<h3 class="text-lg font-bold">Self-Host your Space</h3>
</div>
<div class="badge badge-neutral">Recommended</div>
</div>
<ul class="flex list-inside list-disc flex-col gap-1 text-sm opacity-70">
<li>Unlimited customization and control</li>
<li>Free and open source software</li>
<li>Full-featured admin dashboards available</li>
<li>Requires some technical skills</li>
</ul>
</div>
<Link class="btn btn-primary" href="https://github.com/coracle-social/zooid">
Get Started
<Icon icon={ArrowRight} />
</Link>
</div>
<div class="card2 bg-alt flex flex-col gap-4">
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<img alt="Coracle Logo" src="/coracle.png" class="h-7 w-7" />
<h3 class="text-lg font-bold">Coracle Hosting</h3>
</div>
<div class="badge badge-neutral">Recommended</div>
</div>
<ul class="flex list-inside list-disc flex-col gap-1 text-sm opacity-70">
<li>Simple setup, support included</li>
<li>Free and open source software — no vendor lock-in</li>
<li>Advanced access controls and relay policies</li>
<li>Full-featured admin dashboard</li>
</ul>
</div>
<Link external class="btn btn-neutral" href="https://hosting.coracle.social">
Get Started
<Icon icon={ArrowRight} />
</Link>
</div>
<div class="card2 bg-alt flex flex-col gap-4">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Icon icon={HandShake} />
<h3 class="text-lg font-bold">Holis Communities</h3>
</div>
<ul class="flex list-inside list-disc flex-col gap-1 text-sm opacity-70">
<li>Simple self-serve space creation</li>
<li>Built-in moderation tools</li>
<li>Room-level access controls</li>
<li>Membship lists and invite codes</li>
</ul>
</div>
<Link external class="btn btn-neutral" href="https://hol.is">
Get Started
<Icon icon={ArrowRight} />
</Link>
</div>
<div class="card2 bg-alt flex flex-col gap-4">
<div class="flex flex-col gap-4">
<div class="self-start">
<img
alt="Relay Tools"
src="https://relay.tools/17.svg"
class="-my-20 -ml-2 hidden h-48 dark:block"
style="filter: contrast(50%)" />
<img
alt="Relay Tools"
src="https://relay.tools/19.svg"
class="-my-20 -ml-2 h-48 dark:hidden"
style="filter: contrast(50%)" />
</div>
<ul class="flex list-inside list-disc flex-col gap-1 text-sm opacity-70">
<li>Independently run</li>
<li>Customizable relay policies</li>
<li>Simple management dashboard</li>
<li>Support available</li>
</ul>
</div>
<Link class="btn btn-neutral" href="https://relay.tools/signup">
Get Started
<Icon icon={ArrowRight} />
</Link>
</div>
</div>
</PageContent>
</Page>
+10 -12
View File
@@ -8,11 +8,8 @@ config({path: ".env.template"})
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{html,js,svelte,ts}"],
darkMode: ['selector', '[data-theme="dark"]'],
safelist: [
'bg-success',
'bg-warning',
],
darkMode: ["selector", '[data-theme="dark"]'],
safelist: ["bg-success", "bg-warning"],
theme: {
extend: {},
zIndex: {
@@ -20,11 +17,12 @@ export default {
"nav-active": 1,
"nav-item": 2,
feature: 3,
nav: 4,
popover: 5,
modal: 6,
"modal-feature": 7,
toast: 8,
compose: 4,
nav: 5,
popover: 6,
modal: 7,
"modal-feature": 8,
toast: 9,
},
},
plugins: [daisyui],
@@ -40,8 +38,8 @@ export default {
},
light: {
...themes["winter"],
neutral: '#F2F7FF',
warning: '#FD8D0B',
neutral: "#F2F7FF",
warning: "#FD8D0B",
primary: process.env.VITE_PLATFORM_ACCENT,
"primary-content": process.env.VITE_PLATFORM_ACCENT_CONTENT || "#EAE7FF",
secondary: process.env.VITE_PLATFORM_SECONDARY,