Compare commits

...

11 Commits

Author SHA1 Message Date
Jon Staab 9955a50add Clean up search 2026-06-24 14:44:26 -07:00
Jon Staab 74d3a25461 Bump version
Container Image Build and Publish / build-and-push-image (push) Successful in 16m56s
2026-06-24 11:23:55 -07:00
Jon Staab 1eb75f4ae7 Fix thread page styling 2026-06-24 11:19:13 -07:00
Jon Staab 09adf33333 Fix some space joining errors 2026-06-24 11:09:44 -07:00
Jon Staab fdb4f859ee Add pnpm workspace to docker file
Container Image Build and Publish / build-and-push-image (push) Successful in 15m42s
2026-06-23 13:32:32 -07:00
Jon Staab b428ad3d36 Fix dufflepud url
Container Image Build and Publish / build-and-push-image (push) Failing after 44s
2026-06-23 13:11:18 -07:00
Jon Staab 18b52a37bb Fix keyboard safe insets 2026-06-23 13:03:42 -07:00
Jon Staab 7fba1752a1 Bump version, format 2026-06-23 09:58:08 -07:00
Jon Staab c44c3793fa Split up space information and directory 2026-06-23 06:04:22 -07:00
Jon Staab 7ec5a28d1f Add roles 2026-06-22 15:45:31 -07:00
Jon Staab fd4e7a9f2d Fix doubled side rail and some space navigation 2026-06-19 21:59:05 -07:00
100 changed files with 2272 additions and 1168 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
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_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_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
VITE_DEFAULT_SPACES=https://chat.flotilla.social/ VITE_DEFAULT_SPACES=https://support.flotilla.social/
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
VITE_PLATFORM_URL=https://app.flotilla.social VITE_PLATFORM_URL=https://app.flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms VITE_PLATFORM_TERMS=https://flotilla.social/terms
+3
View File
@@ -170,6 +170,9 @@ src/
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates - Do not define svelte event handlers inline, instead name them and put them in the script section of templates
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly. - Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)` - Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
- Do not render a profile's `about` directly (e.g. `profile.about`); use the `ProfileInfo` component instead.
- Use `type Props` instead of interface when defining props for svelte components.
- When a component's value/prop shape mirrors a subset of an existing type, derive it with `Pick`/`Partial` and `export` that type from the component's `<script module>` (e.g. a `Values` type) for callers to import, instead of re-enumerating its sub-properties.
**Human-First Simplicity (Jon Staab Style):** **Human-First Simplicity (Jon Staab Style):**
+26
View File
@@ -1,5 +1,31 @@
# Changelog # Changelog
# 1.8.2
* Fix thread board styling
* Fix space joining errors
# 1.8.1
* Add space dashboard and directory
* Add space roles
* Fix UI bugs
* Redesign threads as a linear phpBB-style forum view
* Unwrap messages that are only quotes
* Use direct zapping for the donate page, link to flotilla space for support
* Speed up feed loading
* Fix bunker login
* Add welshman skill
* Replace zap slider with common amount pills
* Make join rejections due to an empty claim more forgiving
* Show voice room participants before joining
* Fix direct links to spaces
* Show per-relay publish status on outgoing messages
* Fix A/V call bugs
* Sync checked read state for cross-device badges
* Fix deleted rooms persisting in navigation
* Turn on notification defaults and prompt on first DM visit
# 1.8.0 # 1.8.0
* Fix relay badge overflow * Fix relay badge overflow
+1 -1
View File
@@ -14,7 +14,7 @@ RUN corepack enable
WORKDIR /app WORKDIR /app
ENV NODE_OPTIONS=--max_old_space_size=16384 ENV NODE_OPTIONS=--max_old_space_size=16384
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm i --frozen-lockfile RUN pnpm i --frozen-lockfile
COPY . . COPY . .
ARG VITE_BUILD_HASH ARG VITE_BUILD_HASH
+2 -2
View File
@@ -8,8 +8,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 47 versionCode 49
versionName "1.8.0" versionName "1.8.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
-1
View File
@@ -10,7 +10,6 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':aparajita-capacitor-secure-storage') implementation project(':aparajita-capacitor-secure-storage')
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-clipboard') implementation project(':capacitor-clipboard')
implementation project(':capacitor-filesystem') implementation project(':capacitor-filesystem')
@@ -11,7 +11,6 @@
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:background">@null</item> <item name="android:background">@null</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:ignore="NewApi">true</item>
</style> </style>
+11 -14
View File
@@ -1,39 +1,36 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.3.4_@capacitor+core@8.3.4/node_modules/@capacitor/android/capacitor')
include ':aparajita-capacitor-secure-storage' include ':aparajita-capacitor-secure-storage'
project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android') project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android')
include ':capacitor-community-safe-area'
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area/android')
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.4/node_modules/@capacitor/app/android')
include ':capacitor-clipboard' include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard/android') project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/clipboard/android')
include ':capacitor-filesystem' include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android') project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.2_@capacitor+core@8.3.4/node_modules/@capacitor/filesystem/android')
include ':capacitor-keyboard' include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard/android') project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.3.4/node_modules/@capacitor/keyboard/android')
include ':capacitor-preferences' include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences/android') project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/preferences/android')
include ':capacitor-push-notifications' include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android') project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.4/node_modules/@capacitor/push-notifications/android')
include ':capacitor-share' include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share/android') project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/share/android')
include ':capawesome-capacitor-android-dark-mode-support' include ':capawesome-capacitor-android-dark-mode-support'
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android') project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.1_@capacitor+core@8.3.4/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
include ':capawesome-capacitor-badge' include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android') project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.2_@capacitor+core@8.3.4/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin' include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin/android') project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_fd4b5c957724da9f45e5678eca5c7fd4/node_modules/nostr-signer-capacitor-plugin/android')
+1 -5
View File
@@ -7,22 +7,18 @@ const config: CapacitorConfig = {
ios: { ios: {
scheme: "Flotilla Chat", scheme: "Flotilla Chat",
}, },
android: {
adjustMarginsForEdgeToEdge: true,
},
plugins: { plugins: {
CapacitorHttp: { CapacitorHttp: {
enabled: true, enabled: true,
}, },
SystemBars: { SystemBars: {
insetsHandling: "enable", insetsHandling: "css",
}, },
SplashScreen: { SplashScreen: {
androidSplashResourceName: "splash", androidSplashResourceName: "splash",
}, },
Keyboard: { Keyboard: {
style: "DARK", style: "DARK",
resizeOnFullScreen: true,
}, },
Badge: { Badge: {
persist: true, persist: true,
+4 -4
View File
@@ -372,7 +372,7 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 38; CURRENT_PROJECT_VERSION = 40;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@@ -381,7 +381,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.8.0; MARKETING_VERSION = 1.8.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -401,7 +401,7 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 38; CURRENT_PROJECT_VERSION = 40;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@@ -410,7 +410,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.8.0; MARKETING_VERSION = 1.8.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+12 -13
View File
@@ -1,4 +1,4 @@
require_relative '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios/scripts/pods_helpers' require_relative '../../node_modules/.pnpm/@capacitor+ios@8.3.4_@capacitor+core@8.3.4/node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '15.0' platform :ios, '15.0'
use_frameworks! use_frameworks!
@@ -9,19 +9,18 @@ use_frameworks!
install! 'cocoapods', :disable_input_output_paths => true install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.3.4_@capacitor+core@8.3.4/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.3.4_@capacitor+core@8.3.4/node_modules/@capacitor/ios'
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage' pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area' pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.4/node_modules/@capacitor/app'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app' pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/clipboard'
pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard' pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.2_@capacitor+core@8.3.4/node_modules/@capacitor/filesystem'
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem' pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.3.4/node_modules/@capacitor/keyboard'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard' pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/preferences'
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences' pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.4/node_modules/@capacitor/push-notifications'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications' pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/share'
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share' pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.2_@capacitor+core@8.3.4/node_modules/@capawesome/capacitor-badge'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge' pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_fd4b5c957724da9f45e5678eca5c7fd4/node_modules/nostr-signer-capacitor-plugin'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin'
end end
target 'Flotilla Chat' do target 'Flotilla Chat' do
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.8.0", "version": "1.8.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -45,7 +45,6 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0", "@aparajita/capacitor-secure-storage": "^8.0.0",
"@capacitor-community/safe-area": "^8.0.1",
"@capacitor/android": "^8.0.1", "@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
"@capacitor/cli": "^8.0.1", "@capacitor/cli": "^8.0.1",
@@ -63,7 +62,7 @@
"@getalby/sdk": "^5.1.2", "@getalby/sdk": "^5.1.2",
"@hono/node-server": "^2.0.0", "@hono/node-server": "^2.0.0",
"@noble/curves": "^1.9.7", "@noble/curves": "^1.9.7",
"@pomade/core": "^0.3.0", "@pomade/core": "^0.3.1",
"@poppanator/sveltekit-svg": "^7.0.0", "@poppanator/sveltekit-svg": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2", "@tiptap/core": "^2.27.2",
+14 -26
View File
@@ -14,9 +14,6 @@ importers:
'@aparajita/capacitor-secure-storage': '@aparajita/capacitor-secure-storage':
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0 version: 8.0.0
'@capacitor-community/safe-area':
specifier: ^8.0.1
version: 8.0.1(@capacitor/core@8.3.4)
'@capacitor/android': '@capacitor/android':
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.3.4(@capacitor/core@8.3.4) version: 8.3.4(@capacitor/core@8.3.4)
@@ -69,8 +66,8 @@ importers:
specifier: ^1.9.7 specifier: ^1.9.7
version: 1.9.7 version: 1.9.7
'@pomade/core': '@pomade/core':
specifier: ^0.3.0 specifier: ^0.3.1
version: 0.3.0(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47(@capacitor/core@8.3.4))(nostr-tools@2.23.5(typescript@5.9.3)))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-tools@2.23.5(typescript@5.9.3)) version: 0.3.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47(@capacitor/core@8.3.4))(nostr-tools@2.23.5(typescript@5.9.3)))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-tools@2.23.5(typescript@5.9.3))
'@poppanator/sveltekit-svg': '@poppanator/sveltekit-svg':
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0(rollup@4.60.4)(svelte@5.55.9(@typescript-eslint/types@8.60.0))(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) version: 7.0.0(rollup@4.60.4)(svelte@5.55.9(@typescript-eslint/types@8.60.0))(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
@@ -97,7 +94,7 @@ importers:
version: 1.1.0(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.9(@typescript-eslint/types@8.60.0))(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)))(svelte@5.55.9(@typescript-eslint/types@8.60.0))(typescript@5.9.3)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)))(@vite-pwa/assets-generator@1.0.2)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1) version: 1.1.0(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.9(@typescript-eslint/types@8.60.0))(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)))(svelte@5.55.9(@typescript-eslint/types@8.60.0))(typescript@5.9.3)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)))(@vite-pwa/assets-generator@1.0.2)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1)
'@welshman/app': '@welshman/app':
specifier: ^0.8.16 specifier: ^0.8.16
version: 0.8.16(7683b6be0f65191b839378ceee4e4014) version: 0.8.16(9e2dd3230191940679c41b23e5e365c3)
'@welshman/content': '@welshman/content':
specifier: ^0.8.16 specifier: ^0.8.16
version: 0.8.16(nostr-tools@2.23.5(typescript@5.9.3)) version: 0.8.16(nostr-tools@2.23.5(typescript@5.9.3))
@@ -779,11 +776,6 @@ packages:
'@canvas/image-data@1.1.0': '@canvas/image-data@1.1.0':
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==} resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
'@capacitor-community/safe-area@8.0.1':
resolution: {integrity: sha512-zVVQ7k94DbOff1mHP8qrZGTJZWyhGZnKHD2eXdcobIoOBFW9CeVphLNZYDCop/DBsHXfv6r8k0/Ac8xMIlwT+A==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/android@8.3.4': '@capacitor/android@8.3.4':
resolution: {integrity: sha512-7gJjrG3X32Am1QMLqgMztWTYMLMVNE+VZwhekNxhvYizH4mOV05vH+rC9B+f17bCkYZfyu/qXQX6hoY7kLeVZw==} resolution: {integrity: sha512-7gJjrG3X32Am1QMLqgMztWTYMLMVNE+VZwhekNxhvYizH4mOV05vH+rC9B+f17bCkYZfyu/qXQX6hoY7kLeVZw==}
peerDependencies: peerDependencies:
@@ -1476,9 +1468,9 @@ packages:
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@pomade/core@0.3.0': '@pomade/core@0.3.1':
resolution: {integrity: sha512-zWx0wJftbW92GSIEdLnOk8oUzaGAd0DbOqCBOoNeyCtgn9i4aNy0QsVmYDwxI8xKtVujsxppqk+fMJvinIiEqA==} resolution: {integrity: sha512-lNsM60bu2o9JlqPc47JoAz19QACXS5dNYgvoeApLW8LpxuWy7RcMyHDZ3llklVPYd99PXslFtBdVRpbP26oKyQ==}
version: 0.3.0 version: 0.3.1
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
peerDependencies: peerDependencies:
'@frostr/bifrost': ^1.0.7 '@frostr/bifrost': ^1.0.7
@@ -5847,10 +5839,6 @@ snapshots:
'@canvas/image-data@1.1.0': {} '@canvas/image-data@1.1.0': {}
'@capacitor-community/safe-area@8.0.1(@capacitor/core@8.3.4)':
dependencies:
'@capacitor/core': 8.3.4
'@capacitor/android@8.3.4(@capacitor/core@8.3.4)': '@capacitor/android@8.3.4(@capacitor/core@8.3.4)':
dependencies: dependencies:
'@capacitor/core': 8.3.4 '@capacitor/core': 8.3.4
@@ -6013,7 +6001,7 @@ snapshots:
'@emnapi/runtime@1.10.0': '@emnapi/runtime@1.10.0':
dependencies: dependencies:
tslib: 2.6.2 tslib: 2.8.1
optional: true optional: true
'@esbuild/aix-ppc64@0.25.12': '@esbuild/aix-ppc64@0.25.12':
@@ -6318,7 +6306,7 @@ snapshots:
debug: 4.3.4 debug: 4.3.4
signal-exit: 3.0.7 signal-exit: 3.0.7
tree-kill: 1.2.2 tree-kill: 1.2.2
tslib: 2.6.2 tslib: 2.8.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6336,7 +6324,7 @@ snapshots:
'@ionic/utils-stream@3.1.6': '@ionic/utils-stream@3.1.6':
dependencies: dependencies:
debug: 4.3.4 debug: 4.3.4
tslib: 2.6.2 tslib: 2.8.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6356,7 +6344,7 @@ snapshots:
'@ionic/utils-terminal': 2.3.4 '@ionic/utils-terminal': 2.3.4
cross-spawn: 7.0.6 cross-spawn: 7.0.6
debug: 4.3.4 debug: 4.3.4
tslib: 2.6.2 tslib: 2.8.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6381,7 +6369,7 @@ snapshots:
slice-ansi: 4.0.0 slice-ansi: 4.0.0
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 strip-ansi: 6.0.1
tslib: 2.6.2 tslib: 2.8.1
untildify: 4.0.0 untildify: 4.0.0
wrap-ansi: 7.0.0 wrap-ansi: 7.0.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -6578,7 +6566,7 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@pomade/core@0.3.0(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47(@capacitor/core@8.3.4))(nostr-tools@2.23.5(typescript@5.9.3)))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-tools@2.23.5(typescript@5.9.3))': '@pomade/core@0.3.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47(@capacitor/core@8.3.4))(nostr-tools@2.23.5(typescript@5.9.3)))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-tools@2.23.5(typescript@5.9.3))':
dependencies: dependencies:
'@frostr/bifrost': 1.0.7(typescript@5.9.3) '@frostr/bifrost': 1.0.7(typescript@5.9.3)
'@noble/hashes': 2.2.0 '@noble/hashes': 2.2.0
@@ -7197,9 +7185,9 @@ snapshots:
- workbox-build - workbox-build
- workbox-window - workbox-window
'@welshman/app@0.8.16(7683b6be0f65191b839378ceee4e4014)': '@welshman/app@0.8.16(9e2dd3230191940679c41b23e5e365c3)':
dependencies: dependencies:
'@pomade/core': 0.3.0(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47(@capacitor/core@8.3.4))(nostr-tools@2.23.5(typescript@5.9.3)))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-tools@2.23.5(typescript@5.9.3)) '@pomade/core': 0.3.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47(@capacitor/core@8.3.4))(nostr-tools@2.23.5(typescript@5.9.3)))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-tools@2.23.5(typescript@5.9.3))
'@welshman/feeds': 0.8.16(942b3be06d36b211cee078a14ee828c5) '@welshman/feeds': 0.8.16(942b3be06d36b211cee078a14ee828c5)
'@welshman/lib': 0.8.16 '@welshman/lib': 0.8.16
'@welshman/net': 0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0) '@welshman/net': 0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0)
+12 -3
View File
@@ -85,7 +85,11 @@
} }
@utility card2 { @utility card2 {
@apply rounded-box text-base-content p-4 sm:p-6; @apply rounded-box text-base-content border-base-content/20 bg-base-100 border border-solid p-4 shadow-xl/5 sm:p-6;
}
@utility card2-interactive {
@apply cursor-pointer hover:scale-101 transition-all;
} }
@utility column { @utility column {
@@ -276,6 +280,11 @@
@apply text-base-content p-2 sm:p-4; @apply text-base-content p-2 sm:p-4;
} }
.card2 .card2,
.dialog .card2 {
@apply shadow-none;
}
[data-tip]::before { [data-tip]::before {
@apply overflow-hidden text-ellipsis; @apply overflow-hidden text-ellipsis;
} }
@@ -414,11 +423,11 @@ progress[value]::-webkit-progress-value {
/* content width for fixed elements */ /* content width for fixed elements */
.left-content { .left-content {
@apply md:left-[calc(18.5rem+var(--sail))]; @apply left-sai md:left-[calc(18.5rem+var(--sail))];
} }
.left-content-full { .left-content-full {
@apply md:left-[calc(3.5rem+var(--sail))]; @apply left-sai md:left-[calc(3.5rem+var(--sail))];
} }
/* Keyboard open state adjustments */ /* Keyboard open state adjustments */
+1 -3
View File
@@ -4,9 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>{NAME}</title> <title>{NAME}</title>
<link rel="canonical" href="{URL}" /> <link rel="canonical" href="{URL}" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="{ACCENT}" /> <meta name="theme-color" content="{ACCENT}" />
<meta name="description" content="{DESCRIPTION}" /> <meta name="description" content="{DESCRIPTION}" />
<meta property="og:url" content="{URL}" /> <meta property="og:url" content="{URL}" />
+1 -3
View File
@@ -18,9 +18,7 @@
const h = getTagValue("h", event.tags) const h = getTagValue("h", event.tags)
</script> </script>
<Link <Link class="cv col-3 card2 w-full cursor-pointer" href={makeCalendarPath(url, getAddress(event))}>
class="cv col-3 card2 bg-alt w-full cursor-pointer shadow-md"
href={makeCalendarPath(url, getAddress(event))}>
<CalendarEventHeader {event} /> <CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75"> <span class="whitespace-nowrap py-1 text-sm opacity-75">
+1 -1
View File
@@ -25,7 +25,7 @@
</script> </script>
<Link <Link
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-xl" class="cv col-2 card2 w-full cursor-pointer"
href={makeClassifiedPath(url, getAddress(event))}> href={makeClassifiedPath(url, getAddress(event))}>
{#if title} {#if title}
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
+1 -1
View File
@@ -144,7 +144,7 @@
<div class="relative"> <div class="relative">
{#if warning} {#if warning}
<div class="card2 card2-sm bg-alt row-2"> <div class="card2 card2-sm shadow-none row-2">
<Icon icon={Danger} /> <Icon icon={Danger} />
<p> <p>
This note has been flagged by the author as "{warning}".<br /> This note has been flagged by the author as "{warning}".<br />
+1 -1
View File
@@ -3,7 +3,7 @@
</script> </script>
<code <code
class="w-full overflow-auto whitespace-pre rounded bg-neutral px-1 text-neutral-content" class="w-full overflow-auto whitespace-pre rounded bg-neutral px-1 text-neutral-content text-sm"
class:block={isBlock}> class:block={isBlock}>
{value.trim()} {value.trim()}
</code> </code>
+3 -2
View File
@@ -77,7 +77,8 @@
<span class="loading loading-spinner"></span> <span class="loading loading-spinner"></span>
</div> </div>
{:then preview} {:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal"> <div
class="border border-solid border-base-content/20 flex max-w-xl flex-col leading-normal rounded-box">
{#if preview.image && !hideImage} {#if preview.image && !hideImage}
<img <img
alt="" alt=""
@@ -92,7 +93,7 @@
</div> </div>
</div> </div>
{:catch} {:catch}
<p class="bg-alt p-12 text-center leading-normal"> <p class="border border-solid border-base-content/20 p-12 text-center leading-normal">
Unable to load a preview for {url} Unable to load a preview for {url}
</p> </p>
{/await} {/await}
+14 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {fromNostrURI} from "@welshman/util" import {fromNostrURI} from "@welshman/util"
import {nthEq} from "@welshman/lib" import {nthEq} from "@welshman/lib"
import { import {
@@ -37,10 +38,11 @@
interface Props { interface Props {
event: any event: any
trimParent?: boolean trimParent?: boolean
singleLine?: boolean
url?: string url?: string
} }
const {event, trimParent = false, url}: Props = $props() const {event, trimParent = false, singleLine = false, url}: Props = $props()
const fullContent = parse(event) const fullContent = parse(event)
@@ -96,7 +98,7 @@
<div class="relative"> <div class="relative">
{#if warning} {#if warning}
<div class="card2 card2-sm bg-alt row-2"> <div class="card2 card2-sm shadow-none row-2">
<Icon icon={Danger} /> <Icon icon={Danger} />
<p> <p>
This note has been flagged by the author as "{warning}".<br /> This note has been flagged by the author as "{warning}".<br />
@@ -104,10 +106,18 @@
</p> </p>
</div> </div>
{:else} {:else}
<div class="overflow-hidden text-ellipsis wrap-break-word"> <div
class={cx(
"overflow-hidden text-ellipsis",
singleLine ? "whitespace-nowrap" : "wrap-break-word",
)}>
{#each shortContent as parsed, i} {#each shortContent as parsed, i}
{#if isNewline(parsed)} {#if isNewline(parsed)}
<ContentNewline value={parsed.value} /> {#if singleLine}
{" "}
{:else}
<ContentNewline value={parsed.value} />
{/if}
{:else if isTopic(parsed)} {:else if isTopic(parsed)}
<ContentTopic value={parsed.value} /> <ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)} {:else if isEmoji(parsed)}
+4 -1
View File
@@ -53,7 +53,10 @@
<NoteContentMinimal trimParent {url} event={$quote} /> <NoteContentMinimal trimParent {url} event={$quote} />
</div> </div>
{:else} {:else}
<NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4"> <NoteCard
event={$quote}
{url}
class="border border-solid border-base-content/20 rounded-box p-4">
<NoteContentMinimal {url} event={$quote} /> <NoteContentMinimal {url} event={$quote} />
</NoteCard> </NoteCard>
{/if} {/if}
@@ -0,0 +1,79 @@
<script lang="ts">
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import InputList from "@lib/components/InputList.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {setFeaturedContent} from "@app/featured"
import {pushToast} from "@app/toast"
type Props = {
url: string
initial: string[]
}
const {url, initial}: Props = $props()
let content = $state([...initial])
let loading = $state(false)
const back = () => history.back()
const submit = async () => {
loading = true
try {
const error = await setFeaturedContent(url, content)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Featured content updated!"})
back()
}
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Featured Content</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<Field>
{#snippet info()}
<p>
Each entry is shown on the space's About page. Links will be fetched and displayed
automatically.
</p>
{/snippet}
{#snippet input()}
<InputList bind:value={content} placeholder="URL or nevent...">
{#snippet addLabel()}
Add content
{/snippet}
</InputList>
{/snippet}
</Field>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={submit} disabled={loading}>
<Spinner {loading}>Save changes</Spinner>
</Button>
</ModalFooter>
</Modal>
+1 -3
View File
@@ -20,9 +20,7 @@
const h = getTagValue("h", event.tags) const h = getTagValue("h", event.tags)
</script> </script>
<Link <Link class="cv col-2 card2 w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
href={makeGoalPath(url, event.id)}>
<p class="text-2xl">{event.content}</p> <p class="text-2xl">{event.content}</p>
<Content <Content
event={{content: summary, tags: event.tags}} event={{content: summary, tags: event.tags}}
+3 -5
View File
@@ -16,15 +16,13 @@
children, children,
minimal = false, minimal = false,
hideProfile = false, hideProfile = false,
noShadow = false,
url, url,
...restProps ...restProps
}: { }: {
event: TrustedEvent event: TrustedEvent
children: Snippet children?: Snippet
minimal?: boolean minimal?: boolean
hideProfile?: boolean hideProfile?: boolean
noShadow?: boolean
url?: string url?: string
class?: string class?: string
} = $props() } = $props()
@@ -36,7 +34,7 @@
let muted = $state($isEventMuted(event)) let muted = $state($isEventMuted(event))
</script> </script>
<div class="flex flex-col gap-2 {restProps.class}" class:shadow-md={!noShadow}> <div class="flex flex-col gap-2 {restProps.class}">
{#if muted} {#if muted}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="row-2 relative"> <div class="row-2 relative">
@@ -60,6 +58,6 @@
{formatTimestamp(event.created_at)} {formatTimestamp(event.created_at)}
</Button> </Button>
</div> </div>
{@render children()} {@render children?.()}
{/if} {/if}
</div> </div>
+1 -1
View File
@@ -40,7 +40,7 @@
}) })
</script> </script>
<NoteCard {event} {url} class="cv card2 bg-alt"> <NoteCard {event} {url} class="cv card2">
<NoteContent {event} expandMode="inline" /> <NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right"> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
+1 -1
View File
@@ -18,7 +18,7 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script> </script>
<div class="card2 bg-alt flex flex-col gap-4 shadow-md"> <div class="card2 card2-interactive col-4">
<div class="flex justify-between"> <div class="flex justify-between">
<Profile {pubkey} {url} /> <Profile {pubkey} {url} />
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex"> <Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
+1 -1
View File
@@ -33,7 +33,7 @@
</script> </script>
<div <div
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block"> class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block border-r border-solid border-base-content/15 dark:border-base-content/10">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}> <div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<PrimaryNavSpaces /> <PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0} {#if PLATFORM_RELAYS.length > 0}
@@ -7,9 +7,10 @@
type Props = { type Props = {
url: string url: string
showTooltip?: boolean
} }
const {url}: Props = $props() const {url, showTooltip = true}: Props = $props()
const onClick = () => goToSpace(url) const onClick = () => goToSpace(url)
@@ -21,7 +22,7 @@
<PrimaryNavItem <PrimaryNavItem
href={path} href={path}
onclick={onClick} onclick={onClick}
title={$display} title={showTooltip ? $display : ""}
class="tooltip-right" class="tooltip-right"
notification={$notifications.has(path)}> notification={$notifications.has(path)}>
<RelayIcon {url} size={10} class="rounded-full" /> <RelayIcon {url} size={10} class="rounded-full" />
+28 -7
View File
@@ -3,8 +3,10 @@
import Widget from "@assets/icons/widget-4.svg?dataurl" import Widget from "@assets/icons/widget-4.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte" import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte" import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import PrimaryNavSpacesOverflow from "@app/components/PrimaryNavSpacesOverflow.svelte"
import {userSpaceUrls} from "@app/groups" import {userSpaceUrls} from "@app/groups"
import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env" import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
import {notifications} from "@app/notifications" import {notifications} from "@app/notifications"
@@ -16,6 +18,13 @@
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight)) const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls)) const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p))) const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
// Tippy mounts its content component once, so pass a stable reactive object it can read from
const overflowProps = $state({urls: [] as string[]})
$effect(() => {
overflowProps.urls = secondarySpaceUrls
})
</script> </script>
<svelte:window bind:innerHeight={windowHeight} /> <svelte:window bind:innerHeight={windowHeight} />
@@ -31,12 +40,24 @@
{#each primarySpaceUrls as url (url)} {#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} /> <PrimaryNavItemSpace {url} />
{/each} {/each}
<PrimaryNavItem {#snippet allSpaces(title: string)}
href="/spaces" <PrimaryNavItem
title="All Spaces" href="/spaces"
prefix="no-highlight" {title}
notification={otherSpaceNotifications}> prefix="no-highlight"
<ImageIcon alt="All Spaces" src={Widget} size={8} /> notification={otherSpaceNotifications}>
</PrimaryNavItem> <ImageIcon alt="All Spaces" src={Widget} size={8} />
</PrimaryNavItem>
{/snippet}
{#if secondarySpaceUrls.length > 0}
<Tippy
component={PrimaryNavSpacesOverflow}
props={overflowProps}
params={{placement: "right", interactive: true}}>
{@render allSpaces("")}
</Tippy>
{:else}
{@render allSpaces("All Spaces")}
{/if}
{/each} {/each}
</div> </div>
@@ -0,0 +1,16 @@
<script lang="ts">
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
type Props = {
urls: string[]
}
const {urls}: Props = $props()
</script>
<div
class="flex max-h-[80vh] flex-col overflow-y-auto rounded-box border border-solid border-base-content/15 bg-base-200 p-1 shadow-xl">
{#each urls as url (url)}
<PrimaryNavItemSpace {url} showTooltip={false} />
{/each}
</div>
+2 -2
View File
@@ -33,7 +33,7 @@
const copyPubkey = () => clip(nip19.npubEncode(pubkey)) const copyPubkey = () => clip(nip19.npubEncode(pubkey))
</script> </script>
<div class="flex max-w-full items-start gap-3"> <div class="flex max-w-full items-start gap-2">
{#if inert} {#if inert}
<span class="py-1"> <span class="py-1">
<ProfileCircle {pubkey} size={avatarSize} /> <ProfileCircle {pubkey} size={avatarSize} />
@@ -46,7 +46,7 @@
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if inert} {#if inert}
<span class="text-bold overflow-hidden text-ellipsis"> <span class="text-bold overflow-hidden text-ellipsis whitespace-nowrap">
{$profileDisplay} {$profileDisplay}
</span> </span>
{:else} {:else}
+8 -12
View File
@@ -1,13 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {removeUndefined} from "@welshman/lib" import {removeUndefined} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util" import {deriveProfile, displayProfileByPubkey, loadMessagingRelayList} from "@welshman/app"
import {
manageRelay,
deriveProfile,
displayProfileByPubkey,
loadMessagingRelayList,
} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl"
import Letter from "@assets/icons/letter-opened.svg?dataurl" import Letter from "@assets/icons/letter-opened.svg?dataurl"
@@ -29,7 +23,12 @@
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
import {pubkeyLink} from "@app/env" import {pubkeyLink} from "@app/env"
import {deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems, addSpaceMembers} from "@app/members" import {
deriveUserIsSpaceAdmin,
deriveSpaceBannedPubkeyItems,
addSpaceMembers,
banSpaceMembers,
} from "@app/members"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {goToChat} from "@app/routes" import {goToChat} from "@app/routes"
@@ -68,10 +67,7 @@
title: "Ban User", title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`, message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => { confirm: async () => {
const {error} = await manageRelay(url!, { const error = await banSpaceMembers(url!, [pubkey])
method: ManagementMethod.BanPubkey,
params: [pubkey],
})
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
+3 -2
View File
@@ -5,14 +5,15 @@
export type Props = { export type Props = {
pubkey: string pubkey: string
singleLine?: boolean
url?: string url?: string
} }
const {pubkey, url}: Props = $props() const {pubkey, url, singleLine}: Props = $props()
const profile = deriveProfile(pubkey, removeUndefined([url])) const profile = deriveProfile(pubkey, removeUndefined([url]))
</script> </script>
{#if $profile} {#if $profile}
<ContentMinimal event={{content: $profile.about || "", tags: []}} /> <ContentMinimal event={{content: $profile.about || "", tags: []}} {singleLine} />
{/if} {/if}
+1 -1
View File
@@ -25,7 +25,7 @@
const onClick = () => goto(h ? makeRoomPath(url, h) : makeSpaceChatPath(url)) const onClick = () => goto(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
</script> </script>
<Button class="cv card2 bg-alt shadow-md" onclick={onClick}> <Button class="cv card2" onclick={onClick}>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
{#if h} {#if h}
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import {THREAD, CLASSIFIED, ZAP_GOAL, EVENT_TIME, POLL} from "@welshman/util"
import NoteItem from "@app/components/NoteItem.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import PollItem from "@app/components/PollItem.svelte"
import RecentConversation from "@app/components/RecentConversation.svelte"
import type {RecentActivityItem} from "@app/recent"
type Props = {
url: string
item: RecentActivityItem
}
const {url, item}: Props = $props()
</script>
{#if item.type === "message"}
<RecentConversation {url} event={item.event} count={item.count} />
{:else if item.event.kind === THREAD}
<ThreadItem {url} event={item.event} />
{:else if item.event.kind === CLASSIFIED}
<ClassifiedItem {url} event={item.event} />
{:else if item.event.kind === ZAP_GOAL}
<GoalItem {url} event={item.event} />
{:else if item.event.kind === EVENT_TIME}
<CalendarEventItem {url} event={item.event} />
{:else if item.event.kind === POLL}
<PollItem {url} event={item.event} />
{:else}
<NoteItem {url} event={item.event} />
{/if}
+2 -5
View File
@@ -12,7 +12,7 @@
import Popover from "@lib/components/Popover.svelte" import Popover from "@lib/components/Popover.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import {deriveUserIsSpaceAdmin} from "@app/members" import {deriveUserIsSpaceAdmin, banSpaceMembers} from "@app/members"
import {publishDelete} from "@app/deletes" import {publishDelete} from "@app/deletes"
import {canEnforceNip70} from "@app/relays" import {canEnforceNip70} from "@app/relays"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@@ -91,10 +91,7 @@
title: "Ban User", title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`, message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => { confirm: async () => {
const {error} = await manageRelay(url, { const error = await banSpaceMembers(url, [pubkey], reason)
method: ManagementMethod.BanPubkey,
params: [pubkey, reason],
})
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -12,43 +11,55 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte" import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {addSpaceMembers} from "@app/members" import {addSpaceMembers, assignRole, type SpaceRole} from "@app/members"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
interface Props { interface Props {
url: string url: string
role: SpaceRole
} }
const {url}: Props = $props() const {url, role}: Props = $props()
const back = () => history.back() const back = () => history.back()
const addMember = async () => { let loading = $state(false)
let pubkeys: string[] = $state([])
const submit = async () => {
loading = true loading = true
try { try {
const error = await addSpaceMembers(url, pubkeys) // Ensure they're space members first, then assign the role
const memberError = await addSpaceMembers(url, pubkeys)
if (error) { if (memberError) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: memberError})
} else { return
pushToast({message: "Members have successfully been added!"})
back()
} }
for (const pubkey of pubkeys) {
const error = await assignRole(url, pubkey, role.id)
if (error) {
pushToast({theme: "error", message: error})
return
}
}
pushToast({message: "Members assigned!"})
back()
} finally { } finally {
loading = false loading = false
} }
} }
let loading = $state(false)
let pubkeys: string[] = $state([])
</script> </script>
<Modal> <Modal>
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Add Members</ModalTitle> <ModalTitle>Add to {role.label || "Role"}</ModalTitle>
<ModalSubtitle>to {displayRelayUrl(url)}</ModalSubtitle> <ModalSubtitle>Assign members to this role</ModalSubtitle>
</ModalHeader> </ModalHeader>
<Field> <Field>
{#snippet label()} {#snippet label()}
@@ -64,7 +75,7 @@
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button class="btn btn-primary" onclick={addMember} disabled={loading}> <Button class="btn btn-primary" onclick={submit} disabled={loading || pubkeys.length === 0}>
<Spinner {loading}>Save changes</Spinner> <Spinner {loading}>Save changes</Spinner>
</Button> </Button>
</ModalFooter> </ModalFooter>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import {roleColor, roleColorSoft, type SpaceRole} from "@app/members"
interface Props {
role: SpaceRole
}
const {role}: Props = $props()
</script>
<div
class="badge min-w-0"
style="background-color: {roleColorSoft(role.color)}; border-color: {roleColor(
role.color,
)}; color: {roleColor(role.color)};">
<strong>{role.label || "Untitled Role"}</strong>
</div>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import {randomId} from "@welshman/lib"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RoleForm, {type Values} from "@app/components/RoleForm.svelte"
import {createRole} from "@app/members"
import {pushToast} from "@app/toast"
type Props = {
url: string
}
const {url}: Props = $props()
const back = () => history.back()
let loading = $state(false)
const onSubmit = async ({label, description, color}: Values) => {
loading = true
try {
const error = await createRole(url, randomId(), label, description, color, 0)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Role created!"})
back()
}
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Create Role</ModalTitle>
<ModalSubtitle>in <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<RoleForm {loading} {onSubmit} />
</ModalBody>
</Modal>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RoleForm, {type Values} from "@app/components/RoleForm.svelte"
import {editRole, type SpaceRole} from "@app/members"
import {pushToast} from "@app/toast"
type Props = {
url: string
role: SpaceRole
}
const {url, role}: Props = $props()
const back = () => history.back()
let loading = $state(false)
const onSubmit = async ({label, description, color}: Values) => {
loading = true
try {
const error = await editRole(url, role.id, label, description, color, role.order)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Role updated!"})
back()
}
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Edit Role</ModalTitle>
<ModalSubtitle>in <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<RoleForm {loading} {onSubmit} initialValues={role} />
</ModalBody>
</Modal>
+86
View File
@@ -0,0 +1,86 @@
<script module lang="ts">
import type {SpaceRole} from "@app/members"
export type Values = Pick<SpaceRole, "label" | "description" | "color">
</script>
<script lang="ts">
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {roleColor} from "@app/members"
type Props = {
initialValues?: Partial<Values>
loading?: boolean
onSubmit: (values: Values) => void
}
const {initialValues = {}, loading = false, onSubmit}: Props = $props()
const values: Values = $state({
label: "",
description: "",
color: Math.floor(Math.random() * 256),
...initialValues,
})
const back = () => history.back()
const submit = () => onSubmit(values)
</script>
<div class="flex flex-col gap-4">
<Field>
{#snippet label()}
<p>Name</p>
{/snippet}
{#snippet input()}
<input
bind:value={values.label}
class="input input-bordered w-full"
placeholder="Moderator" />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Description</p>
{/snippet}
{#snippet input()}
<textarea bind:value={values.description} class="textarea textarea-bordered w-full" rows="2"
></textarea>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Color</p>
{/snippet}
{#snippet input()}
<div class="flex items-center gap-3">
<div
class="h-8 w-8 shrink-0 rounded-full border-2 border-base-300"
style="background-color: {roleColor(values.color)}">
</div>
<input
type="range"
min="0"
max="255"
bind:value={values.color}
class="range range-sm grow"
style="color: {roleColor(values.color)}; --range-shdw: {roleColor(values.color)}" />
</div>
{/snippet}
</Field>
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={submit} disabled={loading || !values.label}>
<Spinner {loading}>Save changes</Spinner>
</Button>
</ModalFooter>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import RoleBadge from "@app/components/RoleBadge.svelte"
import type {SpaceRole} from "@app/members"
interface Props {
role: SpaceRole
}
const {role}: Props = $props()
</script>
<div class="flex min-w-0 flex-col gap-2">
<RoleBadge {role} />
{#if role.description}
<p class="text-sm opacity-70">{role.description}</p>
{/if}
</div>
+1 -1
View File
@@ -13,7 +13,7 @@
const maxLength = 5500 const maxLength = 5500
</script> </script>
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}> <div class={cx("text-sm", {"card2 card2-sm shadow-none": props.event.kind !== MESSAGE})}>
{#if path && !isMobile} {#if path && !isMobile}
<Link href={path}> <Link href={path}>
<NoteContent {...props} {minLength} {maxLength} /> <NoteContent {...props} {minLength} {maxLength} />
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import {onMount, tick} from "svelte"
import {get} from "svelte/store"
import {debounce} from "throttle-debounce"
import {load} from "@welshman/net"
import {groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import type {TrustedEvent, Filter} from "@welshman/util"
import {MESSAGE, sortEventsDesc, displayRelayUrl} from "@welshman/util"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {CONTENT_KINDS} from "@app/content"
import {deriveEventsForUrlDesc} from "@app/repository"
import {popModal} from "@app/modal"
import {goToEvent} from "@app/routes"
type Props = {
url: string
h: string
}
const {url, h}: Props = $props()
let term = $state("")
let results = $state<TrustedEvent[]>([])
let loading = $state(false)
let input: HTMLInputElement | undefined = $state()
let controller: AbortController | undefined
const doSearch = debounce(300, async (searchTerm: string, controller: AbortController) => {
if (!searchTerm?.trim()) {
loading = false
results = []
return
}
const filter: Filter = {
kinds: [MESSAGE, ...CONTENT_KINDS],
"#h": [h],
search: searchTerm.trim(),
}
results = get(deriveEventsForUrlDesc(url, [filter]))
try {
const events = await load({
relays: [url],
signal: controller.signal,
filters: [filter],
})
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...results]))
} catch (error) {
// Ignore aborts from superseded searches; surface anything else
if (!(error instanceof DOMException && error.name === "AbortError")) {
throw error
}
} finally {
loading = false
}
})
const onInput = () => {
loading = true
controller?.abort()
controller = new AbortController()
doSearch(term, controller)
}
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
const getAgeSection = (createdAt: number) => {
const age = now() - createdAt
if (age <= DAY) {
return "day"
}
if (age <= WEEK) {
return "week"
}
return "older"
}
const getAgeLabel = (createdAt: number) => {
const age = now() - createdAt
if (age < MINUTE) {
return "Just now"
}
if (age < HOUR) {
return `${Math.floor(age / MINUTE)}m ago`
}
if (age < DAY) {
return `${Math.floor(age / HOUR)}h ago`
}
return `${Math.floor(age / DAY)}d ago`
}
const onResultClick = (event: TrustedEvent) => {
popModal()
goToEvent(event, {keepFocus: true})
}
onMount(() => {
tick().then(() => input?.focus())
return () => controller?.abort()
})
</script>
<Modal class="col-2">
<ModalHeader>
<ModalTitle>Search Content</ModalTitle>
<ModalSubtitle>
on <span class="text-primary">{displayRelayUrl(url)}</span>
</ModalSubtitle>
</ModalHeader>
<ModalBody>
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:this={input}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search this room..."
oninput={onInput} />
</label>
{#if loading}
<Spinner {loading}>Searching...</Spinner>
{:else if eventsByAge.size === 0 && term}
<Spinner {loading}>No results found.</Spinner>
{:else}
{#each eventsByAge as [key, events] (key)}
<p class="text-xs uppercase tracking-wide opacity-60">
{#if key === "day"}
Last 24 Hours
{:else if key === "week"}
Last 7 Days
{:else}
Older
{/if}
</p>
{#each events as event (event.id)}
<Button
class="card2 card2-sm card2-interactive col-2"
onclick={() => onResultClick(event)}>
<NoteCard minimal {event}>
<NoteContentMinimal {event} />
</NoteCard>
<div class="row-2">
<div class="badge badge-sm badge-neutral">
{getAgeLabel(event.created_at)}
</div>
</div>
</Button>
{/each}
{/each}
{/if}
</ModalBody>
</Modal>
+1 -1
View File
@@ -33,7 +33,7 @@
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex min-w-0 items-start gap-2"> <div class="flex min-w-0 items-start gap-2">
<RelayIcon {url} size={5} class="shrink-0 rounded-full md:hidden" /> <RelayIcon {url} size={5} class="shrink-0 rounded-full md:hidden" />
<div class="hidden shrink-0 md:flex md:items-center"> <div class="hidden shrink-0 md:flex md:items-center place-self-center">
{@render leading?.()} {@render leading?.()}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
-104
View File
@@ -1,104 +0,0 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Button from "@lib/components/Button.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import SpaceEdit from "@app/components/SpaceEdit.svelte"
import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileLatest from "@app/components/ProfileLatest.svelte"
import {deriveUserIsSpaceAdmin} from "@app/members"
import {pushModal} from "@app/modal"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = deriveRelay(url)
const owner = $derived($relay?.pubkey)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const back = () => history.back()
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
</script>
<Modal>
<ModalBody>
<div class="flex justify-between">
<div class="relative flex gap-4">
<div class="relative">
<div class="avatar relative">
<div
class="center flex! h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
<RelayIcon {url} size={10} />
</div>
</div>
</div>
<div class="flex min-w-0 flex-col gap-1">
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold">
<RelayName {url} />
</h1>
<p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p>
</div>
</div>
{#if $userIsAdmin}
<Button class="btn btn-primary" onclick={startEdit}>
<Icon icon={Pen} />
Edit
</Button>
{/if}
</div>
<RelayDescription {url} />
{#if $relay?.terms_of_service || $relay?.privacy_policy}
<div class="flex gap-3">
{#if $relay.terms_of_service}
<Link href={$relay.terms_of_service} class="badge badge-neutral flex gap-2">
<Icon icon={BillList} size={4} />
Terms of Service
</Link>
{/if}
{#if $relay.privacy_policy}
<Link href={$relay.privacy_policy} class="badge badge-neutral flex gap-2">
<Icon icon={ShieldUser} size={4} />
Privacy Policy
</Link>
{/if}
</div>
{/if}
<SpaceRelayStatus {url} />
<div class="flex flex-col gap-2">
{#if owner}
<div class="card2 bg-alt">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<Icon icon={UserRounded} />
Latest Updates
</h3>
<ProfileLatest {url} pubkey={owner}>
{#snippet fallback()}
<p class="text-sm opacity-60">No recent posts from the relay admin</p>
{/snippet}
</ProfileLatest>
</div>
{/if}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</Modal>
+1 -1
View File
@@ -126,7 +126,7 @@
<Modal tag="form" onsubmit={preventDefault(trySubmit)}> <Modal tag="form" onsubmit={preventDefault(trySubmit)}>
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Edit a Space</ModalTitle> <ModalTitle>Edit this Space</ModalTitle>
<ModalSubtitle><span class="text-primary">{displayRelayUrl(url)}</span></ModalSubtitle> <ModalSubtitle><span class="text-primary">{displayRelayUrl(url)}</span></ModalSubtitle>
</ModalHeader> </ModalHeader>
<FieldInline> <FieldInline>
@@ -0,0 +1,51 @@
<script lang="ts">
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import EditFeaturedContent from "@app/components/EditFeaturedContent.svelte"
import SpaceRecentSummary from "@app/components/SpaceRecentSummary.svelte"
import {deriveFeaturedContent} from "@app/featured"
import {deriveSupportedMethods} from "@app/relays"
import {pushModal} from "@app/modal"
type Props = {
url: string
}
const {url}: Props = $props()
const content = deriveFeaturedContent(url)
const supportedMethods = deriveSupportedMethods(url)
const canEdit = $derived($supportedMethods.some(m => (m as string) === "signevent"))
const edit = () => pushModal(EditFeaturedContent, {url, initial: $content})
</script>
{#if $content.length > 0 || canEdit}
<div class="card2 bg-alt flex flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<h3 class="flex items-center gap-2 text-lg font-bold">
<Icon icon={Bookmark} />
Featured
</h3>
{#if canEdit}
<Button class="btn btn-square btn-ghost btn-sm" onclick={edit}>
<Icon icon={Pen} />
</Button>
{/if}
</div>
{#if $content.length === 0}
<p class="text-sm opacity-70">No featured content yet.</p>
{:else}
<div class="flex flex-col gap-2">
{#each $content as value (value)}
<Content event={{content: value, tags: []}} {url} />
{/each}
</div>
{/if}
</div>
{:else}
<SpaceRecentSummary {url} />
{/if}
+58 -5
View File
@@ -2,29 +2,36 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {sleep} from "@welshman/lib" import {sleep} from "@welshman/lib"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import {displayRelayUrl, getTagValue, RELAY_INVITE} from "@welshman/util" import {displayRelayUrl, getTagValue, ManagementMethod, RELAY_INVITE} from "@welshman/util"
import {Share} from "@capacitor/share" import {Share} from "@capacitor/share"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Upload from "@assets/icons/upload.svg?dataurl" import Upload from "@assets/icons/upload.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl" import Copy from "@assets/icons/copy.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Divider from "@lib/components/Divider.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import QRCode from "@app/components/QRCode.svelte" import QRCode from "@app/components/QRCode.svelte"
import {clip} from "@app/toast" import {clip, pushToast} from "@app/toast"
import {PLATFORM_URL} from "@app/env" import {PLATFORM_URL} from "@app/env"
import {deriveRelayAuthError} from "@app/relays" import {deriveRelayAuthError, deriveSupportedMethods} from "@app/relays"
import {addSpaceMembers} from "@app/members"
const {url} = $props() const {url} = $props()
const supportedMethods = deriveSupportedMethods(url)
const canAddMembers = $derived($supportedMethods.includes(ManagementMethod.AllowPubkey))
const authError = deriveRelayAuthError(url) const authError = deriveRelayAuthError(url)
let networkError = $state(false) let networkError = $state(false)
const isExplicitAuthError = $derived( const isExplicitAuthError = $derived(
$authError && $authError &&
@@ -54,6 +61,28 @@
let loading = $state(true) let loading = $state(true)
let invite = $state("") let invite = $state("")
let adding = $state(false)
let pubkeys: string[] = $state([])
const addMembers = async () => {
if (pubkeys.length === 0) return
adding = true
try {
const error = await addSpaceMembers(url, pubkeys)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Members have successfully been added!"})
back()
}
} finally {
adding = false
}
}
$effect(() => { $effect(() => {
const relay = displayRelayUrl(url) const relay = displayRelayUrl(url)
const params = new URLSearchParams({r: relay, c: claim}).toString() const params = new URLSearchParams({r: relay, c: claim}).toString()
@@ -124,7 +153,7 @@
<div class="flex w-full gap-2"> <div class="flex w-full gap-2">
{#if canShare} {#if canShare}
<Button <Button
class="input input-bordered flex shrink-0 w-12 items-center justify-center p-0" class="input input-bordered flex w-12 shrink-0 items-center justify-center p-0"
onclick={shareInvite}> onclick={shareInvite}>
<Icon icon={Upload} /> <Icon icon={Upload} />
</Button> </Button>
@@ -152,8 +181,32 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if canAddMembers}
<Divider>or</Divider>
<Field>
{#snippet label()}
<p>Add members directly</p>
{/snippet}
{#snippet input()}
<ProfileMultiSelect bind:value={pubkeys} />
{/snippet}
</Field>
{/if}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-primary grow" onclick={back}>Done</Button> {#if canAddMembers}
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button
class="btn btn-primary"
onclick={addMembers}
disabled={adding || pubkeys.length === 0}>
<Spinner loading={adding}>Save</Spinner>
</Button>
{:else}
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
{/if}
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+3 -4
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import {debounce} from "throttle-debounce" import {debounce} from "throttle-debounce"
import {dissoc, maybe} from "@welshman/lib" import {dissoc, maybe} from "@welshman/lib"
import {goto} from "$app/navigation"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {slideAndFade} from "@lib/transition" import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
@@ -22,7 +21,7 @@
import RelaySummary from "@app/components/RelaySummary.svelte" import RelaySummary from "@app/components/RelaySummary.svelte"
import SpaceJoinSettings from "@app/components/SpaceJoinSettings.svelte" import SpaceJoinSettings from "@app/components/SpaceJoinSettings.svelte"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {makeSpacePath} from "@app/routes" import {goToSpace} from "@app/routes"
import {relaysMostlyRestricted} from "@app/policies" import {relaysMostlyRestricted} from "@app/policies"
import {notificationSettings, setSpaceNotifications} from "@app/settings" import {notificationSettings, setSpaceNotifications} from "@app/settings"
import {parseInviteLink} from "@app/invites" import {parseInviteLink} from "@app/invites"
@@ -68,7 +67,7 @@
} }
await addSpace(url) await addSpace(url)
await goto(makeSpacePath(url), {replaceState: true}) await goToSpace(url)
broadcastUserData([url]) broadcastUserData([url])
relaysMostlyRestricted.update(dissoc(url)) relaysMostlyRestricted.update(dissoc(url))
@@ -124,7 +123,7 @@
<div class="card2 bg-alt flex flex-col gap-4"> <div class="card2 bg-alt flex flex-col gap-4">
<p class="opacity-75">You're about to join:</p> <p class="opacity-75">You're about to join:</p>
<RelaySummary url={inviteData.url} /> <RelaySummary url={inviteData.url} />
<SpaceJoinSettings url={inviteData.url} bind:error bind:notifications /> <SpaceJoinSettings url={inviteData.url} {error} bind:notifications />
</div> </div>
</div> </div>
</div> </div>
+3 -4
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {dissoc, maybe} from "@welshman/lib" import {dissoc, maybe} from "@welshman/lib"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@@ -22,7 +21,7 @@
import {notificationSettings} from "@app/settings" import {notificationSettings} from "@app/settings"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {makeSpacePath} from "@app/routes" import {goToSpace} from "@app/routes"
import {Push} from "@app/push" import {Push} from "@app/push"
type Props = { type Props = {
@@ -56,7 +55,7 @@
} }
await addSpace(url) await addSpace(url)
await goto(makeSpacePath(url), {replaceState: true}) await goToSpace(url)
broadcastUserData([url]) broadcastUserData([url])
relaysMostlyRestricted.update(dissoc(url)) relaysMostlyRestricted.update(dissoc(url))
@@ -82,7 +81,7 @@
<Modal tag="form" onsubmit={preventDefault(join)}> <Modal tag="form" onsubmit={preventDefault(join)}>
<ModalBody> <ModalBody>
<RelaySummary {url} /> <RelaySummary {url} />
<SpaceJoinSettings {url} bind:error bind:notifications /> <SpaceJoinSettings {url} {error} bind:notifications />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}> <Button class="btn btn-link" onclick={back} disabled={loading}>
+1 -1
View File
@@ -10,7 +10,7 @@
notifications: boolean notifications: boolean
} }
let {url, error = $bindable(), notifications = $bindable()}: Props = $props() let {url, error, notifications = $bindable()}: Props = $props()
</script> </script>
<div class="card2 card2-sm bg-alt"> <div class="card2 card2-sm bg-alt">
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import {ManagementMethod} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {fly} from "@lib/transition"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import SpaceMemberRoles from "@app/components/SpaceMemberRoles.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte"
import {removeSpaceMembers, banSpaceMembers, type SpaceRole} from "@app/members"
import {deriveSupportedMethods} from "@app/relays"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
interface Props {
url: string
pubkey: string
roles?: SpaceRole[]
}
const {url, pubkey, roles = []}: Props = $props()
const supportedMethods = deriveSupportedMethods(url)
const canUnallow = $derived($supportedMethods.includes(ManagementMethod.UnallowPubkey))
const canBan = $derived($supportedMethods.includes(ManagementMethod.BanPubkey))
const canAssign = $derived($supportedMethods.some(m => (m as string) === "assignrole"))
const canUnassign = $derived($supportedMethods.some(m => (m as string) === "unassignrole"))
let menuOpen = $state(false)
const back = () => history.back()
const closeMenu = () => (menuOpen = false)
const openProfile = () => {
menuOpen = false
pushModal(ProfileDetail, {pubkey, url})
}
const editRoles = () => {
menuOpen = false
pushModal(SpaceMemberRoles, {url, pubkey})
}
const removeMember = () => {
menuOpen = false
pushModal(Confirm, {
title: "Remove Member",
message: `Remove @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const error = await removeSpaceMembers(url, [pubkey])
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Member has successfully been removed!"})
back()
}
},
})
}
const banMember = () => {
menuOpen = false
pushModal(Confirm, {
title: "Ban Member",
message: `Ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const error = await banSpaceMembers(url, [pubkey])
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Member has successfully been banned!"})
back()
}
},
})
}
</script>
<div class="card2 card2-sm relative">
<button
type="button"
class="absolute inset-0 cursor-pointer rounded-box"
aria-label="View {displayProfileByPubkey(pubkey)}'s profile"
onclick={openProfile}>
</button>
<div class="pointer-events-none relative flex items-start justify-between gap-2">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<Profile {pubkey} {url} inert />
{#if roles.length > 0}
<div class="flex flex-wrap gap-1">
{#each roles as role (role.id)}
<RoleBadge {role} />
{/each}
</div>
{/if}
<div class="line-clamp-1 text-sm opacity-70">
<ProfileInfo {pubkey} {url} singleLine />
</div>
</div>
{#if canAssign || canUnassign || canUnallow || canBan}
<div class="pointer-events-auto relative shrink-0">
<Button class="btn btn-square btn-ghost btn-sm" onclick={() => (menuOpen = !menuOpen)}>
<Icon icon={MenuDots} />
</Button>
{#if menuOpen}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-box bg-base-100 p-2 shadow-md">
{#if canAssign || canUnassign}
<li>
<Button onclick={editRoles}>
<Icon icon={Pen} />
Edit roles
</Button>
</li>
{/if}
{#if canUnallow}
<li>
<Button onclick={removeMember}>
<Icon icon={UserMinus} />
Remove member
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban member
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
</div>
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import {get} from "svelte/store"
import {displayProfileByPubkey} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RoleItem from "@app/components/RoleItem.svelte"
import {deriveSpaceRoles, deriveSpaceMemberRoles, assignRole, unassignRole} from "@app/members"
import {pushToast} from "@app/toast"
interface Props {
url: string
pubkey: string
}
const {url, pubkey}: Props = $props()
const roles = deriveSpaceRoles(url)
const memberRoles = deriveSpaceMemberRoles(url)
const initial = new Set(get(memberRoles).get(pubkey) ?? [])
let selected = $state(new Set(initial))
let loading = $state(false)
const back = () => history.back()
const toggle = (id: string) => {
const next = new Set(selected)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
selected = next
}
const submit = async () => {
loading = true
try {
for (const id of selected) {
if (!initial.has(id)) {
const error = await assignRole(url, pubkey, id)
if (error) {
pushToast({theme: "error", message: error})
return
}
}
}
for (const id of initial) {
if (!selected.has(id)) {
const error = await unassignRole(url, pubkey, id)
if (error) {
pushToast({theme: "error", message: error})
return
}
}
}
pushToast({message: "Roles updated!"})
back()
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Edit Member</ModalTitle>
<ModalSubtitle>
Manage roles for <span class="text-primary">@{displayProfileByPubkey(pubkey)}</span>
</ModalSubtitle>
</ModalHeader>
{#if $roles.length === 0}
<div class="card2 bg-base-200 p-4 text-sm opacity-70">This space has no roles yet.</div>
{:else}
<div class="flex flex-col gap-2">
{#each $roles as role (role.id)}
<label class="card2 card2-sm flex justify-between cursor-pointer gap-3">
<RoleItem {role} />
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={selected.has(role.id)}
onchange={() => toggle(role.id)} />
</label>
{/each}
</div>
{/if}
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={submit} disabled={loading}>
<Spinner {loading}>Save changes</Spinner>
</Button>
</ModalFooter>
</Modal>
-181
View File
@@ -1,181 +0,0 @@
<script lang="ts">
import {ManagementMethod} from "@welshman/util"
import {manageRelay, displayProfileByPubkey} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import {fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RelayName from "@app/components/RelayName.svelte"
import Profile from "@app/components/Profile.svelte"
import SpaceMembersAdd from "@app/components/SpaceMembersAdd.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import {
deriveSpaceMembers,
deriveSpaceBannedPubkeyItems,
deriveUserIsSpaceAdmin,
} from "@app/members"
import {deriveSupportedMethods} from "@app/relays"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
interface Props {
url: string
}
const {url}: Props = $props()
const members = deriveSpaceMembers(url)
const bans = deriveSpaceBannedPubkeyItems(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const supportedMethods = deriveSupportedMethods(url)
const canBan = $derived($supportedMethods.includes(ManagementMethod.BanPubkey))
const canUnallow = $derived($supportedMethods.includes(ManagementMethod.UnallowPubkey))
const back = () => history.back()
const toggleMenu = (pubkey: string) => {
menuPubkey = menuPubkey === pubkey ? undefined : pubkey
}
const closeMenu = () => {
menuPubkey = undefined
}
const showBannedPubkeyItems = () => pushModal(SpaceMembersBanned, {url})
const addMember = () => pushModal(SpaceMembersAdd, {url})
const unallowMember = (pubkey: string) =>
pushModal(Confirm, {
title: "Remove User",
message: `Are you sure you want to remove @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.UnallowPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been removed!"})
back()
}
},
})
const banMember = (pubkey: string) =>
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
back()
}
},
})
let menuPubkey = $state<string | undefined>()
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Members</ModalTitle>
<ModalSubtitle>of <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
{#if $userIsAdmin}
{#if $bans.length > 0}
<Button class="btn btn-neutral" onclick={showBannedPubkeyItems}>
Banned users ({$bans.length})
</Button>
{/if}
{/if}
<div class="flex flex-col gap-2">
{#if $members === undefined}
<div class="card2 bg-base-200 p-4">
<span class="text-error">Member list not available from this space</span>
</div>
{:else if $members.length === 0}
<div class="card2 bg-base-200 p-4">
<span class="text-base-content/70">No members yet</span>
</div>
{:else}
{#each $members as pubkey (pubkey)}
<div class="card2 card2-sm bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
{#if canBan || canUnallow}
<div class="relative">
<Button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
{#if canUnallow}
<li>
<Button onclick={() => unallowMember(pubkey)}>
<Icon icon={UserMinus} />
Remove User
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={() => banMember(pubkey)}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
{#if $userIsAdmin}
<Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} />
Add members
</Button>
{/if}
</ModalFooter>
</Modal>
@@ -76,6 +76,9 @@
<ModalSubtitle>on {displayRelayUrl(url)}</ModalSubtitle> <ModalSubtitle>on {displayRelayUrl(url)}</ModalSubtitle>
</ModalHeader> </ModalHeader>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if $bans.length === 0}
<div class="card2 bg-base-200 p-4 text-sm opacity-70">No banned users.</div>
{/if}
{#each $bans as { pubkey, reason } (pubkey)} {#each $bans as { pubkey, reason } (pubkey)}
<div class="card2 bg-alt relative"> <div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
@@ -0,0 +1,76 @@
<script lang="ts">
import {derived} from "svelte/store"
import {RELAY_ADD_MEMBER, RELAY_JOIN, getPubkeyTagValues} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Profile from "@app/components/Profile.svelte"
import {deriveSpaceMembers} from "@app/members"
import {deriveEventsForUrl} from "@app/repository"
import {makeSpacePath} from "@app/routes"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = deriveRelay(url)
const members = deriveSpaceMembers(url)
const memberEvents = deriveEventsForUrl(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_JOIN]}])
const admins = $derived($relay?.pubkey ? [$relay.pubkey] : [])
const directoryPath = makeSpacePath(url, "directory")
// Members sorted by their most recent join/add event, excluding admins.
const newMembers = derived(
[members, memberEvents, relay],
([$members, $memberEvents, $relay]) => {
const adminSet = new Set($relay?.pubkey ? [$relay.pubkey] : [])
const joinedAt = new Map<string, number>()
for (const event of $memberEvents) {
const pubkeys = event.kind === RELAY_JOIN ? [event.pubkey] : getPubkeyTagValues(event.tags)
for (const pubkey of pubkeys) {
joinedAt.set(pubkey, Math.max(joinedAt.get(pubkey) || 0, event.created_at))
}
}
return $members
.filter(pubkey => !adminSet.has(pubkey))
.sort((a, b) => (joinedAt.get(b) || 0) - (joinedAt.get(a) || 0))
.slice(0, 5)
},
)
</script>
<div class="card2 bg-alt flex flex-col gap-3">
<h3 class="flex items-center gap-2 text-lg font-bold">
<Icon icon={UsersGroup} />
Members
</h3>
{#if admins.length > 0}
<div class="flex flex-col gap-2">
<p class="text-xs uppercase tracking-wide opacity-60">Admins</p>
{#each admins as pubkey (pubkey)}
<Profile {pubkey} {url} />
{/each}
</div>
{/if}
{#if $newMembers.length > 0}
<div class="flex flex-col gap-2">
<p class="text-xs uppercase tracking-wide opacity-60">New members</p>
{#each $newMembers as pubkey (pubkey)}
<Profile {pubkey} {url} />
{/each}
</div>
{/if}
<Link href={directoryPath} class="btn btn-neutral btn-sm">
View all members
<Icon icon={AltArrowRight} size={4} />
</Link>
</div>
+17 -28
View File
@@ -5,8 +5,8 @@
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl" import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl" import Home from "@assets/icons/home.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl" import Danger from "@assets/icons/danger.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl" import Exit from "@assets/icons/logout-3.svg?dataurl"
@@ -29,20 +29,19 @@
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte" import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte" import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import SpaceDetail from "@app/components/SpaceDetail.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte" import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceExit from "@app/components/SpaceExit.svelte" import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import SpaceMembers from "@app/components/SpaceMembers.svelte"
import SpaceActionItems from "@app/components/SpaceActionItems.svelte" import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte" import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte" import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte" import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {ENABLE_ZAPS} from "@app/env" import {ENABLE_ZAPS} from "@app/env"
import {CONTENT_KINDS} from "@app/content" import {CONTENT_KINDS} from "@app/content"
import {deriveSpaceMembers, deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members" import {deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members"
import { import {
deriveUserRooms, deriveUserRooms,
deriveOtherRooms, deriveOtherRooms,
@@ -70,7 +69,6 @@
const userRooms = deriveUserRooms(url) const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url) const otherRooms = deriveOtherRooms(url)
const otherVoiceRooms = deriveOtherVoiceRooms(url) const otherVoiceRooms = deriveOtherVoiceRooms(url)
const members = deriveSpaceMembers(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url) const userIsAdmin = deriveUserIsSpaceAdmin(url)
const actionItems = deriveSpaceActionItems(url) const actionItems = deriveSpaceActionItems(url)
@@ -97,12 +95,10 @@
showMenu = !showMenu showMenu = !showMenu
} }
const showDetail = () => pushModal(SpaceDetail, {url})
const showMembers = () => pushModal(SpaceMembers, {url})
const showActionItems = () => pushModal(SpaceActionItems, {url}) const showActionItems = () => pushModal(SpaceActionItems, {url})
const openSearch = () => pushModal(SpaceSearch, {url})
const canCreateRoom = deriveUserCanCreateRoom(url) const canCreateRoom = deriveUserCanCreateRoom(url)
const createInvite = () => pushModal(SpaceInvite, {url}) const createInvite = () => pushModal(SpaceInvite, {url})
@@ -164,22 +160,6 @@
Create Invite Create Invite
</Button> </Button>
</li> </li>
<li>
<Button onclick={showDetail}>
<Icon icon={RemoteControllerMinimalistic} />
Space Information
</Button>
</li>
<li>
<Button onclick={showMembers}>
<Icon icon={UserRounded} />
{#if $members === undefined}
View Members
{:else}
View Members ({$members.length})
{/if}
</Button>
</li>
{#if $userIsAdmin} {#if $userIsAdmin}
<li> <li>
<Button onclick={showActionItems}> <Button onclick={showActionItems}>
@@ -230,6 +210,9 @@
{/if} {/if}
</div> </div>
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden"> <div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
<SecondaryNavItem href={makeSpacePath(url, "about")}>
<Icon icon={Home} /> Space Details
</SecondaryNavItem>
{#if hasNip29($relay)} {#if hasNip29($relay)}
<SecondaryNavItem href={makeSpacePath(url, "recent")}> <SecondaryNavItem href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity <Icon icon={History} /> Recent Activity
@@ -239,6 +222,9 @@
<Icon icon={ChatRound} /> Chat <Icon icon={ChatRound} /> Chat
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
<Icon icon={UsersGroup} /> Directory
</SecondaryNavItem>
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)} {#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem href={goalsPath}> <SecondaryNavItem href={goalsPath}>
<Icon icon={StarFallMinimalistic} /> Goals <Icon icon={StarFallMinimalistic} /> Goals
@@ -265,6 +251,9 @@
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if hasNip29($relay)} {#if hasNip29($relay)}
<SecondaryNavItem onclick={openSearch}>
<Icon icon={Magnifier} /> Search
</SecondaryNavItem>
{#if $userRooms.length > 0} {#if $userRooms.length > 0}
<div class="h-2 shrink-0"></div> <div class="h-2 shrink-0"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader> <SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
@@ -311,8 +300,8 @@
<div <div
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav"> class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
<VoiceWidget /> <VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}> <Link href={makeSpacePath("about")} class="btn btn-neutral btn-sm h-10">
<SocketStatusIndicator {url} /> <SocketStatusIndicator {url} />
</Button> </Link>
</div> </div>
</div> </div>
@@ -0,0 +1,38 @@
<script lang="ts">
import History from "@assets/icons/history.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import RecentItem from "@app/components/RecentItem.svelte"
import {deriveRecentActivity} from "@app/recent"
import {makeSpacePath} from "@app/routes"
type Props = {
url: string
}
const {url}: Props = $props()
const recentActivity = deriveRecentActivity(url)
const recentPath = makeSpacePath(url, "recent")
</script>
<div class="card2 bg-alt flex flex-col gap-3">
<h3 class="flex items-center gap-2 text-lg font-bold">
<Icon icon={History} />
Recent Activity
</h3>
{#if $recentActivity.length === 0}
<p class="text-sm opacity-70">No recent activity yet.</p>
{:else}
<div class="flex flex-col gap-2">
{#each $recentActivity.slice(0, 3) as item (item.event.id)}
<RecentItem {url} {item} />
{/each}
</div>
{/if}
<Link href={recentPath} class="btn btn-neutral btn-sm">
View all recent activity
<Icon icon={AltArrowRight} size={4} />
</Link>
</div>
@@ -1,70 +0,0 @@
<script lang="ts">
import {deriveRelay} from "@welshman/app"
import Server from "@assets/icons/server.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
interface Props {
url: string
}
const {url}: Props = $props()
const relay = deriveRelay(url)
</script>
<div class="card2 bg-alt flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<Icon icon={Server} />
Relay Details
</h3>
<SocketStatusIndicator {url} />
</div>
{#if $relay}
{@const {pubkey, software, version, supported_nips, limitation} = $relay}
<div class="flex flex-wrap gap-1">
{#if pubkey}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Administrator: <ProfileLink unstyled {pubkey} /></span>
</div>
{/if}
{#if $relay?.contact}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Contact: {$relay.contact}</span>
</div>
{/if}
{#if software}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Software: {software}</span>
</div>
{/if}
{#if version}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Version: {version}</span>
</div>
{/if}
{#if Array.isArray(supported_nips)}
<p class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Supported NIPs: {supported_nips.join(", ")}</span>
</p>
{/if}
{#if limitation?.auth_required}
<p class="badge badge-warning">
<span class="ellipsize">Auth Required</span>
</p>
{/if}
{#if limitation?.payment_required}
<p class="badge badge-warning">
<span class="ellipsize">Payment Required</span>
</p>
{/if}
{#if limitation?.min_pow_difficulty}
<p class="badge badge-warning text-wrap h-auto">
<span class="ellipsize">Min PoW: {limitation?.min_pow_difficulty}</span>
</p>
{/if}
</div>
{/if}
</div>
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import {fly} from "@lib/transition"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RoleCreate from "@app/components/RoleCreate.svelte"
import RoleEdit from "@app/components/RoleEdit.svelte"
import RoleAddMembers from "@app/components/RoleAddMembers.svelte"
import RoleItem from "@app/components/RoleItem.svelte"
import {deriveSpaceRoles, deleteRole, type SpaceRole} from "@app/members"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
interface Props {
url: string
}
const {url}: Props = $props()
const roles = deriveSpaceRoles(url)
let menuRoleId = $state<string | undefined>()
const back = () => history.back()
const closeMenu = () => (menuRoleId = undefined)
const createRole = () => pushModal(RoleCreate, {url})
const editRole = (role: SpaceRole) => {
menuRoleId = undefined
pushModal(RoleEdit, {url, role})
}
const addMembers = (role: SpaceRole) => {
menuRoleId = undefined
pushModal(RoleAddMembers, {url, role})
}
const confirmDelete = (role: SpaceRole) => {
menuRoleId = undefined
pushModal(Confirm, {
title: "Delete Role",
message: `Delete the "${role.label}" role? Members will keep their space membership.`,
confirm: async () => {
const error = await deleteRole(url, role.id)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Role deleted!"})
back()
}
},
})
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Manage Roles</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
{#if $roles.length === 0}
<div class="card2 bg-base-200 p-4 text-sm opacity-70">
No roles yet. Create one to start organizing members.
</div>
{:else}
<div class="flex flex-col gap-2">
{#each $roles as role (role.id)}
<div class="card2 card2-sm bg-alt flex justify-between gap-2">
<RoleItem {role} />
<div class="relative shrink-0">
<Button
class="btn btn-square btn-ghost btn-sm"
onclick={() => (menuRoleId = menuRoleId === role.id ? undefined : role.id)}>
<Icon icon={MenuDots} />
</Button>
{#if menuRoleId === role.id}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button onclick={() => addMembers(role)}>
<Icon icon={AddCircle} />
Add members
</Button>
</li>
<li>
<Button onclick={() => editRole(role)}>
<Icon icon={Pen} />
Edit role
</Button>
</li>
<li>
<Button class="text-error" onclick={() => confirmDelete(role)}>
<Icon icon={TrashBin} />
Delete role
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={createRole}>
<Icon icon={AddCircle} />
Create Role
</Button>
</ModalFooter>
</Modal>
+98 -131
View File
@@ -1,94 +1,66 @@
<script lang="ts"> <script lang="ts">
import {tick} from "svelte" import {onMount, tick} from "svelte"
import {get} from "svelte/store"
import {debounce} from "throttle-debounce" import {debounce} from "throttle-debounce"
import {request} from "@welshman/net" import {load} from "@welshman/net"
import {repository, tracker} from "@welshman/app" import {groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import {formatTimestampAsDate, groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import type {TrustedEvent, Filter} from "@welshman/util" import type {TrustedEvent, Filter} from "@welshman/util"
import {MESSAGE, sortEventsDesc} from "@welshman/util" import {MESSAGE, getTagValue, sortEventsDesc, displayRelayUrl} from "@welshman/util"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import RoomName from "@app/components/RoomName.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {CONTENT_KINDS} from "@app/content" import {CONTENT_KINDS} from "@app/content"
import {deriveEventsForUrlDesc} from "@app/repository"
import {popModal} from "@app/modal"
import {goToEvent} from "@app/routes" import {goToEvent} from "@app/routes"
type Props = { type Props = {
url: string url: string
h?: string
} }
const {url, h}: Props = $props() const {url}: Props = $props()
let term = $state("") let term = $state("")
let show = $state(false)
let results = $state<TrustedEvent[]>([]) let results = $state<TrustedEvent[]>([])
let loading = $state(false) let loading = $state(false)
let input: HTMLInputElement | undefined = $state() let input: HTMLInputElement | undefined = $state()
let controller: AbortController | undefined let controller: AbortController | undefined
const relayStatus = $derived( const doSearch = debounce(300, async (searchTerm: string, controller: AbortController) => {
h ? `Searching this room on relay: ${url}.` : `Searching this space on relay: ${url}.`, if (!searchTerm?.trim()) {
)
const open = () => {
show = true
tick().then(() => input?.focus())
}
const close = () => {
show = false
}
const clear = () => {
term = ""
show = false
loading = false
results = []
controller?.abort()
controller = undefined
}
const getRelayUrls = () => [url]
const getFilter = (searchTerm: string): Filter =>
h
? {kinds: [MESSAGE, ...CONTENT_KINDS], "#h": [h], search: searchTerm}
: {kinds: [MESSAGE, ...CONTENT_KINDS], search: searchTerm}
const getLocalResults = (filter: Filter) =>
repository.query([filter]).filter(event => tracker.getRelays(event.id).has(url))
const search = debounce(300, async (searchTerm: string) => {
controller?.abort()
if (!searchTerm.trim()) {
loading = false loading = false
results = [] results = []
return return
} }
controller = new AbortController() const filter: Filter = {
loading = true kinds: [MESSAGE, ...CONTENT_KINDS],
search: searchTerm.trim(),
}
const filter = getFilter(searchTerm.trim()) results = get(deriveEventsForUrlDesc(url, [filter]))
const localResults = getLocalResults(filter)
results = sortEventsDesc(localResults)
try { try {
const events = await request({ const events = await load({
relays: getRelayUrls(), relays: [url],
autoClose: true,
signal: controller.signal, signal: controller.signal,
filters: [filter], filters: [filter],
}) })
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...localResults])) results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...results]))
} catch (error) { } catch (error) {
// Ignore aborts from superseded searches; surface anything else
if (!(error instanceof DOMException && error.name === "AbortError")) { if (!(error instanceof DOMException && error.name === "AbortError")) {
results = sortEventsDesc(localResults) throw error
} }
} finally { } finally {
loading = false loading = false
@@ -96,7 +68,10 @@
}) })
const onInput = () => { const onInput = () => {
void search(term) loading = true
controller?.abort()
controller = new AbortController()
doSearch(term, controller)
} }
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results)) const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
@@ -133,80 +108,72 @@
return `${Math.floor(age / DAY)}d ago` return `${Math.floor(age / DAY)}d ago`
} }
const onRoomSearchResultClick = (event: TrustedEvent) => { const onResultClick = (event: TrustedEvent) => {
close() popModal()
goToEvent(event, {keepFocus: true}) goToEvent(event, {keepFocus: true})
} }
onMount(() => {
tick().then(() => input?.focus())
return () => controller?.abort()
})
</script> </script>
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}> <Modal class="col-2">
<Icon size={4} icon={Magnifier} /> <ModalHeader>
</button> <ModalTitle>Search Content</ModalTitle>
{#if show} <ModalSubtitle>
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button> on <span class="text-primary">{displayRelayUrl(url)}</span>
<div class="fixed top-sai right-sai left-content z-feature p-2"> </ModalSubtitle>
<div </ModalHeader>
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md" <ModalBody>
transition:fly={{y: -40, duration: 150}}> <label class="input input-sm input-bordered flex w-full items-center gap-2">
<div class="flex justify-between"> <Icon size={4} icon={Magnifier} />
<strong>Search</strong> <input
<Button onclick={clear}> bind:this={input}
<Icon icon={CloseCircle} /> bind:value={term}
</Button> class="min-w-0 grow"
</div> type="text"
<label class="input input-sm input-bordered flex w-full items-center gap-2"> placeholder="Search this space..."
<Icon size={4} icon={Magnifier} /> oninput={onInput} />
<input </label>
bind:this={input} {#if loading}
bind:value={term} <Spinner {loading}>Searching...</Spinner>
class="min-w-0 grow" {:else if eventsByAge.size === 0 && term}
type="text" <Spinner {loading}>No results found.</Spinner>
placeholder={h ? "Search this room..." : "Search this space..."} {:else}
oninput={onInput} /> {#each eventsByAge as [key, events] (key)}
</label> <p class="text-xs uppercase tracking-wide opacity-60">
<div class="max-h-[65vh] overflow-y-auto"> {#if key === "day"}
<p class="mb-2 text-xs opacity-70">{relayStatus}</p> Last 24 Hours
{#if !term} {:else if key === "week"}
<p class="text-sm opacity-70"> Last 7 Days
{h ? "Search for content in this room." : "Search for content in this space."} {:else}
</p> Older
{:else if loading} {/if}
<p class="text-sm opacity-70">Searching...</p> </p>
{:else if eventsByAge.size === 0} {#each events as event (event.id)}
<p class="text-sm opacity-70">No results found.</p> {@const h = getTagValue("h", event.tags)}
{:else} <Button
<div class="col-2"> class="card2 card2-sm card2-interactive col-2"
{#each eventsByAge as [key, events] (key)} onclick={() => onResultClick(event)}>
<div class="col-2"> <NoteCard minimal {event}>
<p class="text-xs uppercase tracking-wide opacity-60"> <NoteContentMinimal {event} />
{#if key === "day"} </NoteCard>
Last 24 Hours <div class="row-2">
{:else if key === "week"} <div class="badge badge-sm badge-neutral">
Last 7 Days {getAgeLabel(event.created_at)}
{:else}
Older
{/if}
</p>
<div class="col-2">
{#each events as event (event.id)}
<button
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
onclick={() => onRoomSearchResultClick(event)}>
<p class="line-clamp-2 text-sm">
{event.content.trim() || "(No text content)"}
</p>
<div class="row-2 text-xs opacity-70">
<span>{getAgeLabel(event.created_at)}</span>
<span>{formatTimestampAsDate(event.created_at)}</span>
</div>
</button>
{/each}
</div>
</div> </div>
{/each} {#if h}
</div> <div class="badge badge-sm badge-neutral">
{/if} <RoomName {url} {h} />
</div> </div>
</div> {/if}
</div> </div>
{/if} </Button>
{/each}
{/each}
{/if}
</ModalBody>
</Modal>
+17 -11
View File
@@ -12,7 +12,7 @@
const {url, h, threads}: Props = $props() const {url, h, threads}: Props = $props()
</script> </script>
<section class="overflow-hidden rounded-box border border-base-content/15 bg-base-100 shadow-sm"> <section class="rounded-box border border-base-content/15 bg-base-100 shadow-sm">
<header <header
class="flex items-center justify-between gap-2 border-b border-base-content/15 bg-base-200/70 px-4 py-2.5"> class="flex items-center justify-between gap-2 border-b border-base-content/15 bg-base-200/70 px-4 py-2.5">
<h2 class="text-sm font-bold sm:text-base"> <h2 class="text-sm font-bold sm:text-base">
@@ -27,14 +27,20 @@
{threads.length === 1 ? "topic" : "topics"} {threads.length === 1 ? "topic" : "topics"}
</span> </span>
</header> </header>
<div <table class="w-full border-collapse">
class="hidden border-b border-base-content/10 bg-base-200/40 px-4 py-2 text-xs font-bold uppercase tracking-wide opacity-60 sm:grid sm:grid-cols-[1fr_8rem_5rem_8rem] sm:gap-x-4"> <thead
<span>Topic</span> class="hidden text-xs font-bold uppercase tracking-wide opacity-60 sm:table-header-group">
<span>Author</span> <tr class="border-b border-base-content/10 bg-base-200/40">
<span class="text-center">Replies</span> <th class="px-4 py-2 text-left font-bold">Topic</th>
<span class="text-right">Last post</span> <th class="w-32 px-4 py-2 text-left font-bold">Author</th>
</div> <th class="w-20 px-4 py-2 text-center font-bold">Replies</th>
{#each threads as event (event.id)} <th class="w-32 px-4 py-2 text-right font-bold">Last post</th>
<ThreadBoardItem {url} {event} /> </tr>
{/each} </thead>
<tbody>
{#each threads as event (event.id)}
<ThreadBoardItem {url} {event} />
{/each}
</tbody>
</table>
</section> </section>
+22 -22
View File
@@ -2,6 +2,7 @@
import {formatTimestamp, max} from "@welshman/lib" import {formatTimestamp, max} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {COMMENT, getTagValue} from "@welshman/util" import {COMMENT, getTagValue} from "@welshman/util"
import {goto} from "$app/navigation"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
@@ -20,29 +21,28 @@
const replyCount = $derived($replies.length) const replyCount = $derived($replies.length)
const lastActive = $derived(max([...$replies, event].map(e => e.created_at))) const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
const title = getTagValue("title", event.tags) const title = getTagValue("title", event.tags)
const path = makeThreadPath(url, event.id)
const goToThread = () => goto(path)
</script> </script>
<Link <tr class="cursor-pointer hover:bg-base-200/40 text-sm" onclick={goToThread}>
href={makeThreadPath(url, event.id)} <td class="px-4 py-2 align-top">
class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 border-b border-base-content/10 px-3 py-3 transition-colors hover:bg-base-200/50 sm:grid-cols-[1fr_8rem_5rem_8rem] sm:items-center sm:gap-x-4 sm:px-4"> <Link href={path} class="ellipsize font-semibold">
<div class="col-span-2 min-w-0 sm:col-span-1"> {title || "Untitled thread"}
<p class="ellipsize text-sm font-bold sm:text-base">{title || "Untitled thread"}</p> </Link>
<p class="ellipsize mt-0.5 text-xs opacity-60 sm:hidden"> </td>
by <ProfileName pubkey={event.pubkey} {url} /> <td class="px-4 py-2 align-middle">
</p> <div class="flex items-center gap-2">
</div> <ProfileCircle pubkey={event.pubkey} {url} size={5} />
<div class="hidden items-center gap-2 sm:flex"> <span class="ellipsize">
<ProfileCircle pubkey={event.pubkey} {url} size={6} /> <ProfileName pubkey={event.pubkey} {url} />
<span class="ellipsize text-sm"> </span>
<ProfileName pubkey={event.pubkey} {url} /> </div>
</span> </td>
</div> <td class="px-4 py-2 align-middle text-right">
<p class="text-right text-xs opacity-75 sm:text-center sm:text-sm">
<span class="opacity-60 sm:hidden">Replies · </span>
{replyCount} {replyCount}
</p> </td>
<p class="text-right text-xs opacity-75 sm:text-sm"> <td class="whitespace-nowrap px-4 py-2 align-middle text-right">
<span class="opacity-60 sm:hidden">Last · </span>
{formatTimestamp(lastActive)} {formatTimestamp(lastActive)}
</p> </td>
</Link> </tr>
+1 -3
View File
@@ -20,9 +20,7 @@
const h = getTagValue("h", event.tags) const h = getTagValue("h", event.tags)
</script> </script>
<Link <Link class="cv col-2 card2 w-full cursor-pointer" href={makeThreadPath(url, event.id)}>
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-xl"
href={makeThreadPath(url, event.id)}>
{#if title} {#if title}
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">{title}</p> <p class="text-xl">{title}</p>
+6
View File
@@ -26,6 +26,12 @@ export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({
...extra, ...extra,
}) })
export const makeDeleteFilter = (kinds: number[], extra: Filter = {}) => ({
kinds: [DELETE],
"#k": kinds.map(String),
...extra,
})
export const REPOST_KINDS = [REPOST, GENERIC_REPOST] export const REPOST_KINDS = [REPOST, GENERIC_REPOST]
export const REACTION_KINDS = [REPORT, DELETE, REACTION] export const REACTION_KINDS = [REPORT, DELETE, REACTION]
+1 -1
View File
@@ -50,7 +50,7 @@ export const DEFAULT_SPACES = fromCsv(import.meta.env.VITE_DEFAULT_SPACES).map(n
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com" export const DUFFLEPUD_URL = "https://dufflepud.coracle.social"
export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL
+48
View File
@@ -0,0 +1,48 @@
import {derived} from "svelte/store"
import {first, now} from "@welshman/lib"
import {APP_DATA, getTagValues} from "@welshman/util"
import type {ManagementMethod} from "@welshman/util"
import {deriveRelay, manageRelay} from "@welshman/app"
import {deriveEventsForUrl} from "@app/repository"
// NIP-78 app data published by the relay's self key. Each featured entry is a
// ["content", <value>] tag (freeform text, intended to be a url or nevent).
export const FEATURED_CONTENT_D = "flotilla/featured-content"
export const deriveFeaturedContent = (url: string) =>
derived(
[deriveRelay(url), deriveEventsForUrl(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}])],
([$relay, $events]) => {
const self = $relay?.self || $relay?.pubkey
const event = (self && $events.find(e => e.pubkey === self)) || first($events)
return getTagValues("content", event?.tags ?? [])
},
)
// Publish the featured content list by asking the relay to sign it with its self
// key (the unofficial NIP-86 "signevent" method).
export const setFeaturedContent = async (
url: string,
content: string[],
): Promise<string | undefined> => {
const template = {
kind: APP_DATA,
created_at: now(),
content: "",
tags: [
["d", FEATURED_CONTENT_D],
...content
.map(value => value.trim())
.filter(Boolean)
.map(value => ["content", value]),
],
}
const {error} = await manageRelay(url, {
method: "signevent" as ManagementMethod,
params: [template as unknown as string],
})
return error
}
+167
View File
@@ -14,6 +14,7 @@ import {
ROOM_MEMBERS, ROOM_MEMBERS,
ROOM_REMOVE_MEMBER, ROOM_REMOVE_MEMBER,
getPubkeyTagValues, getPubkeyTagValues,
getTags,
getTagValue, getTagValue,
getTagValues, getTagValues,
sortEventsAsc, sortEventsAsc,
@@ -23,11 +24,136 @@ import {first, memoize, sortBy, spec, uniq} from "@welshman/lib"
import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app" import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app"
import {get} from "svelte/store" import {get} from "svelte/store"
import {deriveEventsForUrl, deriveRelaySignedEvents} from "@app/repository" import {deriveEventsForUrl, deriveRelaySignedEvents} from "@app/repository"
export const deriveSpaceMembers = (url: string) => export const deriveSpaceMembers = (url: string) =>
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) => derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) =>
uniq(getTagValues("member", event?.tags ?? [])), uniq(getTagValues("member", event?.tags ?? [])),
) )
export const RELAY_ROLE = 33534
export type SpaceRole = {
id: string
label: string
description: string
color: number
order: number
}
// hue is 0-255; map to 0-360deg. Saturation/lightness chosen to read on both themes.
export const roleColorHue = (color: number) => (((color % 256) + 256) % 256) * (360 / 256)
export const roleColor = (color: number) => `hsl(${roleColorHue(color)}, 70%, 50%)`
export const roleColorSoft = (color: number) => `hsl(${roleColorHue(color)}, 70%, 90%)`
export const deriveSpaceRoles = (url: string) =>
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_ROLE]}]), $events => {
const roles: SpaceRole[] = []
for (const event of $events) {
const id = getTagValue("d", event.tags)
if (id) {
roles.push({
id,
label: getTagValue("label", event.tags) ?? "",
description: getTagValue("description", event.tags) ?? "",
color: parseInt(getTagValue("color", event.tags) ?? "0", 10) || 0,
order: parseInt(getTagValue("order", event.tags) ?? "0", 10) || 0,
})
}
}
return sortBy(r => [r.order, r.label] as [number, string], roles)
})
// Map<pubkey, roleId[]> parsed from EXTRA values on ["member", pubkey, ...roleIds] tags
export const deriveSpaceMemberRoles = (url: string) =>
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) => {
const memberRoles = new Map<string, string[]>()
if (event) {
for (const tag of getTags("member", event.tags)) {
const pubkey = tag[1]
const roleIds = tag.slice(2)
if (pubkey) {
memberRoles.set(pubkey, roleIds)
}
}
}
return memberRoles
})
export const createRole = async (
url: string,
id: string,
label: string,
description: string,
color: number,
order: number,
): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "createrole" as ManagementMethod,
params: [id, label, description, color.toString(), order.toString()],
})
return error
}
export const editRole = async (
url: string,
id: string,
label: string,
description: string,
color: number,
order: number,
): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "editrole" as ManagementMethod,
params: [id, label, description, color.toString(), order.toString()],
})
return error
}
export const deleteRole = async (url: string, id: string): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "deleterole" as ManagementMethod,
params: [id],
})
return error
}
export const assignRole = async (
url: string,
pubkey: string,
roleId: string,
): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "assignrole" as ManagementMethod,
params: [pubkey, roleId],
})
return error
}
export const unassignRole = async (
url: string,
pubkey: string,
roleId: string,
): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "unassignrole" as ManagementMethod,
params: [pubkey, roleId],
})
return error
}
export const deriveRoomMembers = (url: string, h: string) => { export const deriveRoomMembers = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}] const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}]
@@ -292,6 +418,47 @@ export const addSpaceMembers = async (
} }
} }
export const removeSpaceMembers = async (
url: string,
pubkeys: string[],
): Promise<string | undefined> => {
const results = await Promise.all(
pubkeys.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.UnallowPubkey,
params: [pubkey],
}),
),
)
for (const {error} of results) {
if (error) {
return error
}
}
}
export const banSpaceMembers = async (
url: string,
pubkeys: string[],
reason = "",
): Promise<string | undefined> => {
const results = await Promise.all(
pubkeys.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.BanPubkey,
params: reason ? [pubkey, reason] : [pubkey],
}),
),
)
for (const {error} of results) {
if (error) {
return error
}
}
}
export const addRoomMembers = async ( export const addRoomMembers = async (
url: string, url: string,
room: PublishedRoomMeta, room: PublishedRoomMeta,
+1 -1
View File
@@ -99,7 +99,7 @@ export const mostlyRestrictedPolicy = (socket: Socket) => {
const pending = new Set<string>() const pending = new Set<string>()
const updateStatus = (error?: string) => { const updateStatus = (error?: string) => {
if (restricted > total / 2) { if (total > 5 && restricted > total / 2) {
if (error) { if (error) {
return relaysMostlyRestricted.update(assoc(socket.url, error)) return relaysMostlyRestricted.update(assoc(socket.url, error))
} }
+2 -2
View File
@@ -26,7 +26,7 @@ import {
import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content" import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content"
import {notificationSettings, shouldNotify, userSettingsValues} from "@app/settings" import {notificationSettings, shouldNotify, userSettingsValues} from "@app/settings"
import {userSpaceUrls} from "@app/groups" import {userSpaceUrls} from "@app/groups"
import {makeSpacePath, getEventPath} from "@app/routes" import {getEventPath, goToSpace} from "@app/routes"
export type PushSubscription = { export type PushSubscription = {
key: string key: string
@@ -111,7 +111,7 @@ export const onPushNotificationAction = async (action: ActionPerformed) => {
if (event) { if (event) {
goto(await getEventPath(event, [relay])) goto(await getEventPath(event, [relay]))
} else { } else {
goto(makeSpacePath(relay)) goToSpace(relay)
} }
} }
+67
View File
@@ -0,0 +1,67 @@
import {derived} from "svelte/store"
import {groupBy, first, sortBy, uniqBy, ago, MONTH} from "@welshman/lib"
import {MESSAGE, COMMENT, getTagValue, getTagValues, getIdAndAddress} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import {deriveEventsForUrl} from "@app/repository"
import {CONTENT_KINDS} from "@app/content"
export type RecentActivityItem = {
type: "message" | "content"
event: TrustedEvent
count: number
timestamp: number
}
// Recent activity for a space: latest message per room plus content with the
// most recent activity (post or comment), sorted newest first.
export const deriveRecentActivity = (url: string) => {
const since = ago(3, MONTH)
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}])
const content = deriveEventsForUrl(url, [{kinds: CONTENT_KINDS, since}])
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], since}])
return derived([messages, content, comments], ([$messages, $content, $comments]) => {
const activity: RecentActivityItem[] = []
const byRoom = groupBy(e => getTagValue("h", e.tags), $messages)
for (const roomMessages of byRoom.values()) {
const latest = first(roomMessages)
if (latest) {
activity.push({
type: "message",
event: latest,
count: roomMessages.length,
timestamp: latest.created_at,
})
}
}
const latestActivityByKey = new Map<string, number>()
for (const event of $content) {
for (const k of getIdAndAddress(event)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
for (const event of $comments) {
for (const k of getTagValues(["E", "A"], event.tags)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
for (const [address, timestamp] of latestActivityByKey.entries()) {
const event = repository.getEvent(address)
if (event) {
activity.push({type: "content", event, timestamp, count: 1})
}
}
return sortBy(
a => -a.timestamp,
uniqBy(a => a.event.id, activity),
)
})
}
+15 -5
View File
@@ -177,14 +177,24 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
return `Failed to connect` return `Failed to connect`
} }
// Some relays are extra slow, remove this when welshman is updated
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.auth.status === AuthStatus.Requested,
})
await socket.auth.attemptAuth(sign) await socket.auth.attemptAuth(sign)
// Some relays are extra slow, remove this when welshman is updated
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.auth.status !== AuthStatus.PendingResponse,
})
if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) { if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) {
if (socket.auth.details) { const message = socket.auth.details || last(socket.auth.status.split(":"))
return `Failed to authenticate (${socket.auth.details})`
} else { return `Failed to authenticate (${message})`
return `Failed to authenticate (${last(socket.auth.status.split(":"))})`
}
} }
const error = await waitForThunkError(publishJoinRequest({url, claim})) const error = await waitForThunkError(publishJoinRequest({url, claim}))
+12 -8
View File
@@ -79,18 +79,22 @@ export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) =>
return path return path
} }
export const goToSpace = async (url: string) => { export const goToSpace = (url: string) => {
const prevPath = lastPageBySpaceUrl.get(encodeRelay(url)) const prevPath = lastPageBySpaceUrl.get(encodeRelay(url))
if (prevPath && prevPath !== makeSpacePath(url)) { if (prevPath && prevPath !== makeSpacePath(url)) {
goto(prevPath, {replaceState: true}) return goto(prevPath, {replaceState: true})
} else if (!hasNip29(getRelay(url))) {
goto(makeSpaceChatPath(url), {replaceState: true})
} else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) {
goto(makeSpacePath(url, "recent"), {replaceState: true})
} else {
goto(makeSpacePath(url), {replaceState: true})
} }
if (!hasNip29(getRelay(url))) {
return goto(makeSpaceChatPath(url), {replaceState: true})
}
if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) {
return goto(makeSpacePath(url, "about"), {replaceState: true})
}
return goto(makeSpacePath(url), {replaceState: true})
} }
// Content types, events // Content types, events
+6 -2
View File
@@ -20,6 +20,7 @@ import {
RELAY_REMOVE_MEMBER, RELAY_REMOVE_MEMBER,
MESSAGE, MESSAGE,
POLL_RESPONSE, POLL_RESPONSE,
APP_DATA,
isSignedEvent, isSignedEvent,
unionFilters, unionFilters,
} from "@welshman/util" } from "@welshman/util"
@@ -53,6 +54,8 @@ import {
} from "@app/groups" } from "@app/groups"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {loadFeedsForPubkey} from "@app/feeds" import {loadFeedsForPubkey} from "@app/feeds"
import {RELAY_ROLE} from "@app/members"
import {FEATURED_CONTENT_D} from "@app/featured"
import {hasBlossomSupport} from "@app/uploads" import {hasBlossomSupport} from "@app/uploads"
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice" import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
@@ -268,7 +271,7 @@ const syncUserData = () => {
const syncSpace = (url: string) => { const syncSpace = (url: string) => {
const since = ago(WEEK) const since = ago(WEEK)
const controller = new AbortController() const controller = new AbortController()
const relayKinds = [RELAY_MEMBERS] const relayKinds = [RELAY_MEMBERS, RELAY_ROLE]
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS] const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE] const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE]
@@ -277,8 +280,9 @@ const syncSpace = (url: string) => {
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]}, {kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
makeCommentFilter(CONTENT_KINDS, {since}), {kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]},
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since}, {kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
makeCommentFilter(CONTENT_KINDS, {since}),
], ],
}) })
+1
View File
@@ -14,6 +14,7 @@
style?: string style?: string
disabled?: boolean disabled?: boolean
"data-tip"?: string "data-tip"?: string
"aria-label"?: string
"aria-pressed"?: boolean "aria-pressed"?: boolean
} = $props() } = $props()
+2 -2
View File
@@ -8,13 +8,13 @@
const {...props}: Props = $props() const {...props}: Props = $props()
</script> </script>
<div class="content-padding-t content-padding-x flex h-full flex-col gap-1 {props.class}"> <div class="col-1 h-full {props.class}">
<div class="z-feature"> <div class="z-feature">
<div class="content-sizing"> <div class="content-sizing">
{@render props.input?.()} {@render props.input?.()}
</div> </div>
</div> </div>
<div class="scroll-container content-sizing h-full overflow-auto pt-2"> <div class="scroll-container content-sizing h-full pt-2">
{@render props.content?.()} {@render props.content?.()}
</div> </div>
</div> </div>
+1 -1
View File
@@ -44,7 +44,7 @@
) )
</script> </script>
<div class="center fixed inset-0 z-modal"> <div class="dialog center fixed inset-0 z-modal">
<button <button
type="button" type="button"
aria-label="Close dialog" aria-label="Close dialog"
+1 -1
View File
@@ -13,7 +13,7 @@
<div <div
class={cx( class={cx(
"mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav", "mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav border-r border-solid border-base-content/5 dark:border-base-content/10",
visible ? "flex" : "hidden md:flex", visible ? "flex" : "hidden md:flex",
props.class, props.class,
)}> )}>
+3 -2
View File
@@ -4,12 +4,13 @@
interface Props { interface Props {
loading?: boolean loading?: boolean
children?: import("svelte").Snippet children?: import("svelte").Snippet
class?: string
} }
const {loading = false, children}: Props = $props() const {loading = false, children, ...props}: Props = $props()
</script> </script>
<span class="flex min-h-10 items-center"> <span class="flex min-h-10 items-center justify-center {props.class}">
{#if loading} {#if loading}
<span class="pr-3" transition:slide|local={{axis: "x"}}> <span class="pr-3" transition:slide|local={{axis: "x"}}>
<span class="loading loading-spinner" transition:fade|local={{duration: 100}}></span> <span class="loading loading-spinner" transition:fade|local={{duration: 100}}></span>
-1
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import "@src/app.css" import "@src/app.css"
import "@welshman/editor/index.css" import "@welshman/editor/index.css"
import "@capacitor-community/safe-area"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import type {Unsubscriber} from "svelte/store" import type {Unsubscriber} from "svelte/store"
import {get} from "svelte/store" import {get} from "svelte/store"
+24 -21
View File
@@ -6,6 +6,7 @@
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte" import Page from "@lib/components/Page.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import ContentSearch from "@lib/components/ContentSearch.svelte" import ContentSearch from "@lib/components/ContentSearch.svelte"
import PeopleItem from "@app/components/PeopleItem.svelte" import PeopleItem from "@app/components/PeopleItem.svelte"
import {bootstrapPubkeys} from "@app/social" import {bootstrapPubkeys} from "@app/social"
@@ -38,25 +39,27 @@
</script> </script>
<Page> <Page>
<ContentSearch> <PageContent class="col-2 p-2 sm:col-4 sm:p-4">
{#snippet input()} <ContentSearch>
<label class="row-2 input input-bordered w-full"> {#snippet input()}
<Icon icon={Magnifier} /> <label class="row-2 input input-bordered w-full">
<!-- svelte-ignore a11y_autofocus --> <Icon icon={Magnifier} />
<input <!-- svelte-ignore a11y_autofocus -->
autofocus={!isMobile} <input
bind:value={term} autofocus={!isMobile}
class="grow" bind:value={term}
type="text" class="grow"
placeholder="Search for people..." /> type="text"
</label> placeholder="Search for people..." />
{/snippet} </label>
{#snippet content()} {/snippet}
<div class="col-2 h-full" bind:this={element}> {#snippet content()}
{#each pubkeys.slice(0, limit) as pubkey (pubkey)} <div class="col-2 h-full" bind:this={element}>
<PeopleItem {pubkey} /> {#each pubkeys.slice(0, limit) as pubkey (pubkey)}
{/each} <PeopleItem {pubkey} />
</div> {/each}
{/snippet} </div>
</ContentSearch> {/snippet}
</ContentSearch>
</PageContent>
</Page> </Page>
+92 -114
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {onMount, tick} from "svelte" import {onMount} from "svelte"
import {flip} from "svelte/animate" import {flip} from "svelte/animate"
import {cubicOut} from "svelte/easing" import {cubicOut} from "svelte/easing"
import {derived as _derived} from "svelte/store" import {derived as _derived} from "svelte/store"
@@ -8,13 +8,12 @@
import {ROOMS} from "@welshman/util" import {ROOMS} from "@welshman/util"
import {throttled} from "@welshman/store" import {throttled} from "@welshman/store"
import {pull, relays, createSearch} from "@welshman/app" import {pull, relays, createSearch} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller, isMobile} from "@lib/html"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import DragHandle from "@assets/icons/drag-handle.svg?dataurl" import DragHandle from "@assets/icons/drag-handle.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl" import Widget from "@assets/icons/widget-4.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Page from "@lib/components/Page.svelte" import Page from "@lib/components/Page.svelte"
@@ -22,6 +21,7 @@
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ContentSearch from "@lib/components/ContentSearch.svelte"
import RelaySummary from "@app/components/RelaySummary.svelte" import RelaySummary from "@app/components/RelaySummary.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte" import SpaceAdd from "@app/components/SpaceAdd.svelte"
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte" import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
@@ -154,8 +154,6 @@
}) })
let term = $state("") let term = $state("")
let showSearch = $state(false)
let searchInput: HTMLInputElement | undefined = $state()
let limit = $state(20) let limit = $state(20)
let element: Element let element: Element
let orderedSpaceUrls = $state<string[]>([]) let orderedSpaceUrls = $state<string[]>([])
@@ -164,16 +162,6 @@
let lastDragTarget = $state<string | undefined>() let lastDragTarget = $state<string | undefined>()
let didDrop = $state(false) let didDrop = $state(false)
const openSearch = () => {
showSearch = true
tick().then(() => searchInput?.focus())
}
const closeSearch = () => {
showSearch = false
term = ""
}
const inviteData = $derived(parseInviteLink(term)) const inviteData = $derived(parseInviteLink(term))
const searchResults = $derived($relaySearch.searchOptions(term)) const searchResults = $derived($relaySearch.searchOptions(term))
const userSpaceSet = $derived(new Set($userSpaceUrls)) const userSpaceSet = $derived(new Set($userSpaceUrls))
@@ -213,32 +201,6 @@
<strong>Spaces</strong> <strong>Spaces</strong>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}>
<Icon size={4} icon={Magnifier} />
</button>
{#if showSearch}
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={closeSearch}
></button>
<div class="fixed top-sai right-sai left-content-full z-feature p-2">
<div
class="card2 card2-sm p-2! bg-alt flex flex-col shadow-md"
transition:fly={{y: -40, duration: 150}}>
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:this={searchInput}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search for spaces..."
onkeydown={e => e.key === "Escape" && closeSearch()} />
<Button onclick={closeSearch} class="flex items-center">
<Icon icon={CloseCircle} />
</Button>
</label>
</div>
</div>
{/if}
{#if PLATFORM_RELAYS.length === 0} {#if PLATFORM_RELAYS.length === 0}
<Button class="btn btn-primary btn-sm" onclick={addSpace}> <Button class="btn btn-primary btn-sm" onclick={addSpace}>
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
@@ -248,82 +210,98 @@
</div> </div>
</div> </div>
</PageBar> </PageBar>
<PageContent class="flex flex-col gap-2 p-2"> <PageContent class="col-2 p-2 sm:col-4 sm:p-4">
<div class="flex flex-col gap-2" bind:this={element}> <ContentSearch>
{#each PLATFORM_RELAYS as url (url)} {#snippet input()}
<Button <label class="row-2 input input-bordered w-full">
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1]" <Icon icon={Magnifier} />
onclick={() => openSpace(url)}> <!-- svelte-ignore a11y_autofocus -->
<RelaySummary {url} /> <input
</Button> autofocus={!isMobile}
{:else} bind:value={term}
{#await loadUserGroupList()} class="min-w-0 grow"
<div class="flex items-center justify-center py-20"> type="text"
<span class="loading loading-spinner mr-3"></span> placeholder="Search for spaces..." />
Loading your spaces... </label>
</div> {/snippet}
{:then} {#snippet content()}
{#if inviteData} <div class="col-2" bind:this={element}>
<Divider>Search results</Divider> {#each PLATFORM_RELAYS as url (url)}
{#key inviteData.url} <Button class="card2 card2-interactive" onclick={() => openSpace(url)}>
<Button <RelaySummary {url} />
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1]"
onclick={() => openSpace(inviteData.url, inviteData.claim)}>
<RelaySummary url={inviteData.url} />
</Button>
{/key}
{/if}
{#if filteredUserUrls.length > 0}
<Divider>Your spaces</Divider>
{#each filteredUserUrls as url (url)}
<div
animate:flip={{duration: 300, easing: cubicOut}}
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, url)}
ondragover={onDragOver}
ondragenter={e => onDragEnter(e, url)}
ondrop={e => onDrop(e, url)}
ondragend={onDragEnd}>
<Button
class="group card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1] w-full relative min-w-0"
onclick={() => openSpace(url)}>
<div class="flex w-full items-start gap-2">
<div
class="mt-4 flex cursor-grab p-1 text-base-content/30 transition-colors group-hover:text-base-content/60">
<Icon icon={DragHandle} />
</div>
<RelaySummary hideFavorites {url} />
</div>
{#if $notifications.has(makeSpacePath(url))}
<div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</Button>
</div>
{/each}
{:else if !term}
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
{/if}
<Divider>{filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"}</Divider>
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
<Button
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1]"
onclick={() => openSpace(relay.url)}>
<RelaySummary url={relay.url} />
</Button> </Button>
{/each} {:else}
<div class="flex justify-center py-20"> {#await loadUserGroupList()}
{#await sleep(5000)} <div class="flex items-center justify-center py-20">
<Spinner loading>Looking for spaces...</Spinner> <span class="loading loading-spinner mr-3"></span>
Loading your spaces...
</div>
{:then} {:then}
{#if otherSpaces.length === 0} {#if inviteData}
<Spinner>No other spaces found.</Spinner> <Divider>Search results</Divider>
{#key inviteData.url}
<Button
class="card2 card2-interactive"
onclick={() => openSpace(inviteData.url, inviteData.claim)}>
<RelaySummary url={inviteData.url} />
</Button>
{/key}
{/if} {/if}
{#if filteredUserUrls.length > 0}
<Divider>Your spaces</Divider>
{#each filteredUserUrls as url (url)}
<div
animate:flip={{duration: 300, easing: cubicOut}}
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, url)}
ondragover={onDragOver}
ondragenter={e => onDragEnter(e, url)}
ondrop={e => onDrop(e, url)}
ondragend={onDragEnd}>
<Button
class="group card2 card2-interactive w-full relative min-w-0"
onclick={() => openSpace(url)}>
<div class="flex w-full items-start gap-2">
<div
class="mt-4 flex cursor-grab p-1 text-base-content/30 transition-colors group-hover:text-base-content/60">
<Icon icon={DragHandle} />
</div>
<RelaySummary hideFavorites {url} />
</div>
{#if $notifications.has(makeSpacePath(url))}
<div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</Button>
</div>
{/each}
{:else if !term}
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
{/if}
{#if otherSpaces.length > 0}
<Divider>{filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"}</Divider>
{/if}
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
<Button
class="card2 card2-interactive"
onclick={() => openSpace(relay.url)}>
<RelaySummary url={relay.url} />
</Button>
{/each}
<div class="flex justify-center py-20">
{#await sleep(5000)}
<Spinner loading>Looking for spaces...</Spinner>
{:then}
{#if otherSpaces.length === 0}
<Spinner>No other spaces found.</Spinner>
{/if}
{/await}
</div>
{/await} {/await}
</div> {/each}
{/await} </div>
{/each} {/snippet}
</div> </ContentSearch>
</PageContent> </PageContent>
</Page> </Page>
+1 -1
View File
@@ -10,7 +10,7 @@
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
const md = parseFloat(theme.screens.md) * 16 const md = parseFloat(theme.screens.md) * 16
let width = $state(0) let width = $state(window.innerWidth)
$effect(() => { $effect(() => {
if (width > md) { if (width > md) {
+9 -3
View File
@@ -14,6 +14,7 @@
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl" import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl" import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl" import Login2 from "@assets/icons/login-3.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import cx from "classnames" import cx from "classnames"
import {fade, fly} from "@lib/transition" import {fade, fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -28,7 +29,7 @@
import RoomDetail from "@app/components/RoomDetail.svelte" import RoomDetail from "@app/components/RoomDetail.svelte"
import RoomItem from "@app/components/RoomItem.svelte" import RoomItem from "@app/components/RoomItem.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte" import RoomSearch from "@app/components/RoomSearch.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte" import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte" import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
@@ -424,7 +425,12 @@
<RoomName {url} {h} /> <RoomName {url} {h} />
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<SpaceSearch {url} {h} /> <Button
class="btn btn-neutral btn-sm btn-square"
aria-label="Search"
onclick={() => pushModal(RoomSearch, {url, h})}>
<Icon size={4} icon={Magnifier} />
</Button>
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}> <Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} /> <Icon size={4} icon={InfoCircle} />
</Button> </Button>
@@ -483,7 +489,7 @@
</div> </div>
</div> </div>
{:else} {:else}
{#if loadingForward} {#if loadingForward && elements.length > 0}
<p class="py-20 flex justify-center"> <p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner> <Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p> </p>
@@ -0,0 +1,122 @@
<script lang="ts">
import {page} from "$app/stores"
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import Pen from "@assets/icons/pen.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import SpaceEdit from "@app/components/SpaceEdit.svelte"
import SpaceMembersSummary from "@app/components/SpaceMembersSummary.svelte"
import SpaceFeaturedContent from "@app/components/SpaceFeaturedContent.svelte"
import {deriveUserIsSpaceAdmin} from "@app/members"
import {decodeRelay} from "@app/relays"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!)
const relay = deriveRelay(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
</script>
<PageContent class="flex flex-col gap-4 p-4">
<div class="card2 bg-alt flex flex-col gap-4">
<div class="flex justify-between">
<div class="relative flex gap-4">
<div class="relative">
<RelayIcon {url} size={14} class="rounded-full" />
</div>
<div class="flex min-w-0 flex-col">
<h1 class="ellipsize whitespace-nowrap">
<RelayName {url} class="text-2xl font-bold" />
</h1>
<p class="ellipsize text-sm text-primary">{displayRelayUrl(url)}</p>
</div>
</div>
{#if $userIsAdmin}
<Button class="btn btn-primary" onclick={startEdit}>
<Icon icon={Pen} />
Edit
</Button>
{/if}
</div>
<RelayDescription {url} />
{#if $relay?.terms_of_service || $relay?.privacy_policy}
<div class="flex gap-3">
{#if $relay.terms_of_service}
<Link href={$relay.terms_of_service} class="badge badge-neutral flex gap-2">
<Icon icon={BillList} size={4} />
Terms of Service
</Link>
{/if}
{#if $relay.privacy_policy}
<Link href={$relay.privacy_policy} class="badge badge-neutral flex gap-2">
<Icon icon={ShieldUser} size={4} />
Privacy Policy
</Link>
{/if}
</div>
{/if}
{#if $relay}
{@const {pubkey, software, version, supported_nips, limitation} = $relay}
<div class="flex flex-wrap gap-1">
{#if pubkey}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Administrator: <ProfileLink unstyled {pubkey} /></span>
</div>
{/if}
{#if $relay?.contact}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Contact: {$relay.contact}</span>
</div>
{/if}
{#if software}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Software: {software}</span>
</div>
{/if}
{#if version}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Version: {version}</span>
</div>
{/if}
{#if Array.isArray(supported_nips)}
<p class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Supported NIPs: {supported_nips.join(", ")}</span>
</p>
{/if}
{#if limitation?.auth_required}
<p class="badge badge-warning">
<span class="ellipsize">Auth Required</span>
</p>
{/if}
{#if limitation?.payment_required}
<p class="badge badge-warning">
<span class="ellipsize">Payment Required</span>
</p>
{/if}
{#if limitation?.min_pow_difficulty}
<p class="badge badge-warning text-wrap h-auto">
<span class="ellipsize">Min PoW: {limitation?.min_pow_difficulty}</span>
</p>
{/if}
</div>
{/if}
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="lg:col-span-2 flex flex-col gap-4">
<SpaceFeaturedContent {url} />
</div>
<div class="flex flex-col gap-4">
<SpaceMembersSummary {url} />
</div>
</div>
</PageContent>
@@ -126,9 +126,9 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2"> <PageContent bind:element class="flex flex-col gap-2 p-2 sm:px-4">
{#each items as { event, dateDisplay, isFirstFutureEvent }, i (event.id)} {#each items as { event, dateDisplay, isFirstFutureEvent }, i (event.id)}
<div class={"calendar-event-" + event.id}> <div class="flex flex-col gap-2 calendar-event-{event.id}">
{#if isFirstFutureEvent} {#if isFirstFutureEvent}
<div class="flex items-center gap-2 p-2"> <div class="flex items-center gap-2 p-2">
<div class="h-px grow bg-primary"></div> <div class="h-px grow bg-primary"></div>
@@ -65,7 +65,7 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent class="flex flex-col gap-3 p-2"> <PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#if $event} {#if $event}
<div class="card2 bg-alt col-3 z-feature"> <div class="card2 bg-alt col-3 z-feature">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
+9 -2
View File
@@ -12,6 +12,7 @@
import {fade, fly} from "@lib/transition" import {fade, fly} from "@lib/transition"
import ChatRound from "@assets/icons/chat-round.svg?dataurl" import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
@@ -32,6 +33,7 @@
import {publishDelete} from "@app/deletes" import {publishDelete} from "@app/deletes"
import {checked} from "@app/notifications" import {checked} from "@app/notifications"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {makeFeed} from "@app/feeds" import {makeFeed} from "@app/feeds"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
@@ -308,12 +310,17 @@
<strong>Chat</strong> <strong>Chat</strong>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<SpaceSearch {url} /> <Button
class="btn btn-neutral btn-sm btn-square"
aria-label="Search"
onclick={() => pushModal(SpaceSearch, {url})}>
<Icon size={4} icon={Magnifier} />
</Button>
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex-col-reverse !mb-0"> <PageContent bind:element onscroll={onScroll} class="flex-col-reverse !mb-0">
{#if loadingForward} {#if loadingForward && elements.length > 0}
<p class="py-20 flex justify-center"> <p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner> <Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p> </p>
@@ -77,7 +77,7 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2"> <PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#each items as event (event.id)} {#each items as event (event.id)}
<div in:fly> <div in:fly>
<ClassifiedItem {url} event={$state.snapshot(event)} /> <ClassifiedItem {url} event={$state.snapshot(event)} />
@@ -62,7 +62,7 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent class="flex flex-col gap-2 p-2"> <PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#if $event} {#if $event}
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full"> <NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
@@ -0,0 +1,150 @@
<script lang="ts">
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {displayProfileByPubkey} from "@welshman/app"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import SpaceMember from "@app/components/SpaceMember.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceRoles from "@app/components/SpaceRoles.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import {
deriveSpaceRoles,
deriveSpaceMembers,
deriveSpaceMemberRoles,
deriveUserIsSpaceAdmin,
type SpaceRole,
} from "@app/members"
import {decodeRelay} from "@app/relays"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!)
const roles = deriveSpaceRoles(url)
const members = deriveSpaceMembers(url)
const memberRoles = deriveSpaceMemberRoles(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
// Each member with their resolved roles (sorted by order).
const memberList = derived([members, memberRoles, roles], ([$members, $memberRoles, $roles]) => {
const byId = new Map($roles.map(role => [role.id, role]))
return $members.map(pubkey => ({
pubkey,
roleList: ($memberRoles.get(pubkey) ?? [])
.map(id => byId.get(id))
.filter((role): role is SpaceRole => Boolean(role)),
}))
})
let menuOpen = $state(false)
const inviteMembers = () => {
menuOpen = false
pushModal(SpaceInvite, {url})
}
const manageRoles = () => {
menuOpen = false
pushModal(SpaceRoles, {url})
}
const bannedMembers = () => {
menuOpen = false
pushModal(SpaceMembersBanned, {url})
}
// In-place search: filter member cards by member info, and keep role sections
// whose name matches the term even when their members don't.
let term = $state("")
const matchesTerm = (pubkey: string, t: string) =>
displayProfileByPubkey(pubkey).toLowerCase().includes(t) || pubkey.toLowerCase().includes(t)
// In-place search: match by member info or by the name of any role they hold.
const visibleMembers = $derived.by(() => {
const t = term.trim().toLowerCase()
if (!t) return $memberList
return $memberList.filter(
({pubkey, roleList}) =>
matchesTerm(pubkey, t) || roleList.some(role => role.label.toLowerCase().includes(t)),
)
})
</script>
<SpaceBar>
{#snippet leading()}
<Icon icon={UsersGroup} />
{/snippet}
{#snippet title()}
<strong>Members</strong>
{/snippet}
{#snippet action()}
<button class="btn btn-primary btn-sm" onclick={inviteMembers}>
<Icon icon={AddCircle} />
Invite people
</button>
{#if $userIsAdmin}
<div class="relative">
<button
class="btn btn-neutral btn-sm btn-square"
aria-label="More options"
onclick={() => (menuOpen = !menuOpen)}>
<Icon size={4} icon={MenuDots} />
</button>
{#if menuOpen}
<Popover hideOnClick onClose={() => (menuOpen = false)}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button onclick={manageRoles}>
<Icon icon={UsersGroup} />
Manage Roles
</Button>
</li>
<li>
<Button onclick={bannedMembers}>
<Icon icon={MinusCircle} />
Banned Members
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
{/if}
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-4 p-4">
<div class="card2 bg-alt flex flex-col gap-2">
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search people or roles..." />
</label>
{#if visibleMembers.length === 0}
<p class="flex flex-col items-center py-20 text-center">No members found.</p>
{:else}
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2 xl:grid-cols-3">
{#each visibleMembers as { pubkey, roleList } (pubkey)}
<SpaceMember {url} {pubkey} roles={roleList} />
{/each}
</div>
{/if}
</div>
</PageContent>
+1 -1
View File
@@ -77,7 +77,7 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2"> <PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#each items as event (event.id)} {#each items as event (event.id)}
<div in:fly> <div in:fly>
<GoalItem {url} event={$state.snapshot(event)} /> <GoalItem {url} event={$state.snapshot(event)} />
@@ -63,7 +63,7 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent class="flex flex-col gap-2 p-2"> <PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#if $event} {#if $event}
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full"> <NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
+1 -1
View File
@@ -77,7 +77,7 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2"> <PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#each items as event (event.id)} {#each items as event (event.id)}
<div in:fly> <div in:fly>
<PollItem {url} event={$state.snapshot(event)} /> <PollItem {url} event={$state.snapshot(event)} />
@@ -64,7 +64,7 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent class="flex flex-col gap-2 p-2"> <PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#if $event} {#if $event}
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full"> <NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
+12 -271
View File
@@ -1,205 +1,28 @@
<script lang="ts"> <script lang="ts">
import {tick, onMount} from "svelte" import {onMount} from "svelte"
import {derived} from "svelte/store"
import {page} from "$app/stores" import {page} from "$app/stores"
import {debounce} from "throttle-debounce"
import {
formatTimestampAsDate,
groupBy,
ago,
now,
MONTH,
MINUTE,
HOUR,
DAY,
WEEK,
first,
sortBy,
uniqBy,
} from "@welshman/lib"
import {request} from "@welshman/net"
import {
MESSAGE,
THREAD,
CLASSIFIED,
ZAP_GOAL,
EVENT_TIME,
COMMENT,
POLL,
getTagValue,
getTagValues,
getIdAndAddress,
sortEventsDesc,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import History from "@assets/icons/history.svg?dataurl" import History from "@assets/icons/history.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteItem from "@app/components/NoteItem.svelte" import RecentItem from "@app/components/RecentItem.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte" import SpaceSearch from "@app/components/SpaceSearch.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import PollItem from "@app/components/PollItem.svelte"
import RecentConversation from "@app/components/RecentConversation.svelte"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {deriveEventsForUrl} from "@app/repository" import {deriveRecentActivity} from "@app/recent"
import {CONTENT_KINDS} from "@app/content" import {pushModal} from "@app/modal"
import {goToEvent} from "@app/routes"
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
const since = ago(3, MONTH)
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}]) const recentActivity = deriveRecentActivity(url)
const content = deriveEventsForUrl(url, [{kinds: CONTENT_KINDS, since}])
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], since}])
const recentActivity = derived( const openSearch = () => pushModal(SpaceSearch, {url})
[messages, content, comments],
([$messages, $content, $comments]) => {
const activity: Array<{
type: "message" | "content"
event: TrustedEvent
count: number
timestamp: number
}> = []
const byRoom = groupBy(e => getTagValue("h", e.tags), $messages)
for (const roomMessages of byRoom.values()) {
const latest = first(roomMessages)
if (latest) {
activity.push({
type: "message",
event: latest,
count: roomMessages.length,
timestamp: latest.created_at,
})
}
}
const latestActivityByKey = new Map<string, number>()
for (const event of $content) {
for (const k of getIdAndAddress(event)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
for (const event of $comments) {
for (const k of getTagValues(["E", "A"], event.tags)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
for (const [address, timestamp] of latestActivityByKey.entries()) {
const event = repository.getEvent(address)
if (event) {
activity.push({type: "content", event, timestamp, count: 1})
}
}
return sortBy(
a => -a.timestamp,
uniqBy(a => a.event.id, activity),
)
},
)
let term = $state("")
let showSearch = $state(false)
let loading = $state(false)
let searchResults: TrustedEvent[] = $state([])
let searchInput: HTMLInputElement | undefined = $state()
let controller: AbortController | undefined
let limit = $state(20) let limit = $state(20)
let element: Element | undefined = $state() let element: Element | undefined = $state()
const resultsByAge = $derived(groupBy(e => getAgeSection(e.created_at), searchResults))
const getAgeSection = (createdAt: number) => {
const age = now() - createdAt
if (age <= DAY) return "day"
if (age <= WEEK) return "week"
return "older"
}
const getAgeLabel = (createdAt: number) => {
const age = now() - createdAt
if (age < MINUTE) return "Just now"
if (age < HOUR) return `${Math.floor(age / MINUTE)}m ago`
if (age < DAY) return `${Math.floor(age / HOUR)}h ago`
return `${Math.floor(age / DAY)}d ago`
}
const openSearch = () => {
showSearch = true
tick().then(() => searchInput?.focus())
}
const closeSearch = () => {
showSearch = false
}
const clearSearch = () => {
term = ""
showSearch = false
loading = false
searchResults = []
controller?.abort()
controller = undefined
}
const search = debounce(300, async (searchTerm: string) => {
controller?.abort()
if (!searchTerm.trim()) {
loading = false
searchResults = []
return
}
controller = new AbortController()
loading = true
try {
const events = await request({
relays: [url],
autoClose: true,
signal: controller.signal,
filters: [{kinds: [MESSAGE, ...CONTENT_KINDS], search: searchTerm.trim()}],
})
searchResults = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, events))
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
searchResults = []
}
} finally {
loading = false
}
})
const onInput = () => {
showSearch = true
void search(term)
}
const onResultClick = (event: TrustedEvent) => {
closeSearch()
goToEvent(event, {keepFocus: true})
}
onMount(() => { onMount(() => {
const scroller = createScroller({ const scroller = createScroller({
element: element!, element: element!,
@@ -220,100 +43,18 @@
<strong>Recent Activity</strong> <strong>Recent Activity</strong>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}> <Button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}>
<Icon size={4} icon={Magnifier} /> <Icon size={4} icon={Magnifier} />
</button> </Button>
{#if showSearch}
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={closeSearch}
></button>
<div class="fixed top-sai right-sai left-content z-feature p-2">
<div
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
transition:fly={{y: -40, duration: 150}}>
<div class="flex justify-between">
<strong>Search</strong>
<Button onclick={clearSearch}>
<Icon icon={CloseCircle} />
</Button>
</div>
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:this={searchInput}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search this space..."
oninput={onInput} />
</label>
<div class="max-h-[65vh] overflow-y-auto">
{#if !term}
<p class="text-sm opacity-70">Search for content across this space.</p>
{:else if loading}
<p class="text-sm opacity-70">Searching...</p>
{:else if resultsByAge.size === 0}
<p class="text-sm opacity-70">No results found.</p>
{:else}
<div class="col-2">
{#each resultsByAge as [key, events] (key)}
<div class="col-2">
<p class="text-xs uppercase tracking-wide opacity-60">
{#if key === "day"}
Last 24 Hours
{:else if key === "week"}
Last 7 Days
{:else}
Older
{/if}
</p>
<div class="col-2">
{#each events as event (event.id)}
<button
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
onclick={() => onResultClick(event)}>
<p class="line-clamp-2 text-sm">
{event.content.trim() ||
getTagValue("title", event.tags) ||
"(No text content)"}
</p>
<div class="row-2 text-xs opacity-70">
<span>{getAgeLabel(event.created_at)}</span>
<span>{formatTimestampAsDate(event.created_at)}</span>
</div>
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent class="flex flex-col gap-2 p-2" bind:element> <PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4" bind:element>
{#if $recentActivity.length === 0} {#if $recentActivity.length === 0}
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p> <p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
{:else} {:else}
{#each $recentActivity.slice(0, limit) as { type, event, count = 0 } (event.id)} {#each $recentActivity.slice(0, limit) as item (item.event.id)}
{#if type === "message"} <RecentItem {url} {item} />
<RecentConversation {url} {event} {count} />
{:else if event.kind === THREAD}
<ThreadItem {url} {event} />
{:else if event.kind === CLASSIFIED}
<ClassifiedItem {url} {event} />
{:else if event.kind === ZAP_GOAL}
<GoalItem {url} {event} />
{:else if event.kind === EVENT_TIME}
<CalendarEventItem {url} {event} />
{:else if event.kind === POLL}
<PollItem {url} {event} />
{:else}
<NoteItem {url} {event} />
{/if}
{/each} {/each}
{/if} {/if}
</PageContent> </PageContent>
@@ -83,7 +83,7 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent bind:element class="flex flex-col gap-4 p-2"> <PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#each threadFeed.boards as [h, threads] (h || "general")} {#each threadFeed.boards as [h, threads] (h || "general")}
<ThreadBoard {url} {h} {threads} /> <ThreadBoard {url} {h} {threads} />
{/each} {/each}
@@ -93,8 +93,6 @@
Looking for threads... Looking for threads...
{:else if threadFeed.items.length === 0} {:else if threadFeed.items.length === 0}
No threads found. No threads found.
{:else}
That's all!
{/if} {/if}
</Spinner> </Spinner>
</p> </p>
+3 -3
View File
@@ -10,7 +10,7 @@
</script> </script>
<Page> <Page>
<PageContent class="flex flex-col items-center gap-2 p-2"> <PageContent class="flex flex-col items-center gap-2 p-2 sm:gap-4 sm:p-4">
<PageHeader> <PageHeader>
{#snippet title()} {#snippet title()}
<div>Choose your Hosting Plan</div> <div>Choose your Hosting Plan</div>
@@ -23,7 +23,7 @@
</PageHeader> </PageHeader>
<div class="flex w-full max-w-lg flex-col gap-4 lg:max-w-4xl"> <div class="flex w-full max-w-lg flex-col gap-4 lg:max-w-4xl">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
<div class="card2 bg-alt flex flex-col gap-5"> <div class="card2 flex flex-col gap-5">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-md"> <div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-md">
<Icon icon={CloudCheck} class="text-primary" /> <Icon icon={CloudCheck} class="text-primary" />
@@ -59,7 +59,7 @@
<Icon icon={ArrowRight} /> <Icon icon={ArrowRight} />
</Link> </Link>
</div> </div>
<div class="card2 bg-alt border-primary flex flex-col gap-5 border"> <div class="card2 border-primary flex flex-col gap-5 border">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<img alt="Coracle Logo" src="/coracle.png" class="h-10 w-10" /> <img alt="Coracle Logo" src="/coracle.png" class="h-10 w-10" />