Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6655e7973 | |||
| 7fba1752a1 | |||
| c44c3793fa | |||
| 7ec5a28d1f | |||
| fd4e7a9f2d | |||
| deb2b31466 | |||
| b86632e86e | |||
| 3f96b5547c | |||
| eebd764a18 | |||
| 3945685554 |
@@ -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_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_PLATFORM_URL=https://app.flotilla.social
|
||||
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
||||
|
||||
@@ -170,6 +170,9 @@ src/
|
||||
- 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.
|
||||
- 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):**
|
||||
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
# 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
|
||||
|
||||
* Fix relay badge overflow
|
||||
|
||||
@@ -8,8 +8,8 @@ android {
|
||||
applicationId "social.flotilla"
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
targetSdk rootProject.ext.targetSdkVersion
|
||||
versionCode 47
|
||||
versionName "1.8.0"
|
||||
versionCode 48
|
||||
versionName "1.8.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:ignore="NewApi">true</item>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
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'
|
||||
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')
|
||||
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor-community/safe-area/android')
|
||||
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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
@@ -7,22 +7,18 @@ const config: CapacitorConfig = {
|
||||
ios: {
|
||||
scheme: "Flotilla Chat",
|
||||
},
|
||||
android: {
|
||||
adjustMarginsForEdgeToEdge: true,
|
||||
},
|
||||
plugins: {
|
||||
CapacitorHttp: {
|
||||
enabled: true,
|
||||
},
|
||||
SystemBars: {
|
||||
insetsHandling: "enable",
|
||||
insetsHandling: "disable",
|
||||
},
|
||||
SplashScreen: {
|
||||
androidSplashResourceName: "splash",
|
||||
},
|
||||
Keyboard: {
|
||||
style: "DARK",
|
||||
resizeOnFullScreen: true,
|
||||
},
|
||||
Badge: {
|
||||
persist: true,
|
||||
|
||||
@@ -372,7 +372,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
CURRENT_PROJECT_VERSION = 39;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
@@ -381,7 +381,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.8.0;
|
||||
MARKETING_VERSION = 1.8.1;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -401,7 +401,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
CURRENT_PROJECT_VERSION = 39;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
@@ -410,7 +410,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.8.0;
|
||||
MARKETING_VERSION = 1.8.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
+13
-13
@@ -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'
|
||||
use_frameworks!
|
||||
@@ -9,19 +9,19 @@ use_frameworks!
|
||||
install! 'cocoapods', :disable_input_output_paths => true
|
||||
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :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.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.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 '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.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.0.1/node_modules/@capacitor/clipboard'
|
||||
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.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
|
||||
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.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.0.1/node_modules/@capacitor/share'
|
||||
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_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin'
|
||||
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.3.4/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 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.3.4/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 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.3.4/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 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.4/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 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.2_@capacitor+core@8.3.4/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'
|
||||
end
|
||||
|
||||
target 'Flotilla Chat' do
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flotilla",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@getalby/sdk": "^5.1.2",
|
||||
"@hono/node-server": "^2.0.0",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@pomade/core": "^0.3.0",
|
||||
"@pomade/core": "^0.3.1",
|
||||
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@tiptap/core": "^2.27.2",
|
||||
|
||||
Generated
+14
-14
@@ -69,8 +69,8 @@ importers:
|
||||
specifier: ^1.9.7
|
||||
version: 1.9.7
|
||||
'@pomade/core':
|
||||
specifier: ^0.3.0
|
||||
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))
|
||||
specifier: ^0.3.1
|
||||
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':
|
||||
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))
|
||||
@@ -97,7 +97,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)
|
||||
'@welshman/app':
|
||||
specifier: ^0.8.16
|
||||
version: 0.8.16(7683b6be0f65191b839378ceee4e4014)
|
||||
version: 0.8.16(9e2dd3230191940679c41b23e5e365c3)
|
||||
'@welshman/content':
|
||||
specifier: ^0.8.16
|
||||
version: 0.8.16(nostr-tools@2.23.5(typescript@5.9.3))
|
||||
@@ -1476,9 +1476,9 @@ packages:
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@pomade/core@0.3.0':
|
||||
resolution: {integrity: sha512-zWx0wJftbW92GSIEdLnOk8oUzaGAd0DbOqCBOoNeyCtgn9i4aNy0QsVmYDwxI8xKtVujsxppqk+fMJvinIiEqA==}
|
||||
version: 0.3.0
|
||||
'@pomade/core@0.3.1':
|
||||
resolution: {integrity: sha512-lNsM60bu2o9JlqPc47JoAz19QACXS5dNYgvoeApLW8LpxuWy7RcMyHDZ3llklVPYd99PXslFtBdVRpbP26oKyQ==}
|
||||
version: 0.3.1
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
'@frostr/bifrost': ^1.0.7
|
||||
@@ -6013,7 +6013,7 @@ snapshots:
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
dependencies:
|
||||
tslib: 2.6.2
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
@@ -6318,7 +6318,7 @@ snapshots:
|
||||
debug: 4.3.4
|
||||
signal-exit: 3.0.7
|
||||
tree-kill: 1.2.2
|
||||
tslib: 2.6.2
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6336,7 +6336,7 @@ snapshots:
|
||||
'@ionic/utils-stream@3.1.6':
|
||||
dependencies:
|
||||
debug: 4.3.4
|
||||
tslib: 2.6.2
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6356,7 +6356,7 @@ snapshots:
|
||||
'@ionic/utils-terminal': 2.3.4
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.3.4
|
||||
tslib: 2.6.2
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6381,7 +6381,7 @@ snapshots:
|
||||
slice-ansi: 4.0.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
tslib: 2.6.2
|
||||
tslib: 2.8.1
|
||||
untildify: 4.0.0
|
||||
wrap-ansi: 7.0.0
|
||||
transitivePeerDependencies:
|
||||
@@ -6578,7 +6578,7 @@ snapshots:
|
||||
|
||||
'@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:
|
||||
'@frostr/bifrost': 1.0.7(typescript@5.9.3)
|
||||
'@noble/hashes': 2.2.0
|
||||
@@ -7197,9 +7197,9 @@ snapshots:
|
||||
- workbox-build
|
||||
- workbox-window
|
||||
|
||||
'@welshman/app@0.8.16(7683b6be0f65191b839378ceee4e4014)':
|
||||
'@welshman/app@0.8.16(9e2dd3230191940679c41b23e5e365c3)':
|
||||
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/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)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 340 KiB |
+20
-206
@@ -2,15 +2,10 @@
|
||||
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Lato", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: "Baloo 2", "Lato", ui-rounded, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* root */
|
||||
|
||||
:root {
|
||||
font-family: var(--font-sans);
|
||||
font-family: Lato;
|
||||
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||
@@ -90,7 +85,7 @@
|
||||
}
|
||||
|
||||
@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 column {
|
||||
@@ -158,15 +153,15 @@
|
||||
}
|
||||
|
||||
@utility heading {
|
||||
@apply font-display text-center text-2xl font-bold tracking-tight;
|
||||
@apply text-center text-2xl;
|
||||
}
|
||||
|
||||
@utility brand {
|
||||
@apply font-display text-primary font-bold tracking-tight;
|
||||
@utility subheading {
|
||||
@apply text-center text-xl;
|
||||
}
|
||||
|
||||
@utility label {
|
||||
@apply font-display text-sm font-semibold tracking-wider uppercase opacity-70;
|
||||
@utility superheading {
|
||||
@apply text-center text-4xl;
|
||||
}
|
||||
|
||||
@utility link {
|
||||
@@ -220,19 +215,8 @@
|
||||
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Lato-Light.ttf") format("truetype");
|
||||
}
|
||||
|
||||
/* Lato ships Regular + Bold only; map 600 (semibold) and 700 (bold) to the
|
||||
Bold file so the browser never synthesizes a faux-bold. */
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: normal;
|
||||
font-weight: 600 700;
|
||||
font-style: bold;
|
||||
font-weight: 600;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Lato-Bold.ttf") format("truetype");
|
||||
@@ -244,38 +228,13 @@
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Lato-Italic.ttf") format("truetype");
|
||||
}
|
||||
|
||||
/* Baloo 2 — rounded, friendly display face (self-hosted, Latin subset). */
|
||||
@font-face {
|
||||
font-family: "Baloo 2";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/Baloo2-Medium.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Baloo 2";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/Baloo2-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Baloo 2";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/Baloo2-Bold.woff2") format("woff2");
|
||||
url("/fonts/Italic.ttf") format("truetype");
|
||||
}
|
||||
|
||||
/* root */
|
||||
|
||||
:root {
|
||||
font-family: var(--font-sans);
|
||||
font-family: Lato;
|
||||
text-size-adjust: 100%;
|
||||
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||
@@ -317,6 +276,11 @@
|
||||
@apply text-base-content p-2 sm:p-4;
|
||||
}
|
||||
|
||||
.card2 .card2,
|
||||
.dialog .card2 {
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
[data-tip]::before {
|
||||
@apply overflow-hidden text-ellipsis;
|
||||
}
|
||||
@@ -325,7 +289,7 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* editors */
|
||||
/* tiptap */
|
||||
|
||||
.input-editor,
|
||||
.chat-editor,
|
||||
@@ -364,11 +328,7 @@
|
||||
}
|
||||
|
||||
.chat-editor .tiptap {
|
||||
@apply bg-base-300 rounded-[1.5rem] pr-12 transition-shadow;
|
||||
}
|
||||
|
||||
.chat-editor:focus-within .tiptap {
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary), transparent 55%);
|
||||
@apply rounded-box bg-base-300 pr-12;
|
||||
}
|
||||
|
||||
.note-editor .tiptap {
|
||||
@@ -459,11 +419,11 @@ progress[value]::-webkit-progress-value {
|
||||
/* content width for fixed elements */
|
||||
|
||||
.left-content {
|
||||
@apply md:left-[calc(18.5rem+var(--sail))];
|
||||
@apply left-sai md:left-[calc(18.5rem+var(--sail))];
|
||||
}
|
||||
|
||||
.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 */
|
||||
@@ -493,149 +453,3 @@ body.keyboard-open .chat__compose {
|
||||
.chat__scroll-down {
|
||||
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
|
||||
}
|
||||
|
||||
/* shape, depth & motion */
|
||||
|
||||
/* Accessibility: neutralize all motion when the user asks for it. Decorative
|
||||
motion is otherwise opt-in via `motion-safe:` and the guards below. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Soft, diffuse elevation — replaces ad-hoc hard `shadow-md` uses. */
|
||||
@utility shadow-soft {
|
||||
box-shadow:
|
||||
0 4px 16px -4px oklch(0% 0 0 / 0.18),
|
||||
0 1px 3px oklch(0% 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* Organic "hand-drawn" avatar masks. The image (or gradient fallback) fills
|
||||
the blob; three variants are chosen deterministically by pubkey hash so a
|
||||
person's shape stays stable across the app. */
|
||||
@utility avatar-blob {
|
||||
border-radius: 42% 58% 54% 46% / 58% 46% 54% 42%;
|
||||
}
|
||||
@utility avatar-blob-2 {
|
||||
border-radius: 60% 40% 46% 54% / 43% 57% 43% 57%;
|
||||
}
|
||||
@utility avatar-blob-3 {
|
||||
border-radius: 47% 53% 62% 38% / 50% 62% 38% 50%;
|
||||
}
|
||||
|
||||
/* Friendly rounded-square for space / relay / room tiles. */
|
||||
@utility squircle {
|
||||
border-radius: 30%;
|
||||
}
|
||||
|
||||
/* Every DaisyUI button speaks in the rounded display voice and presses in. */
|
||||
.btn {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.btn {
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Motion vocabulary ---- */
|
||||
@keyframes nav-button-pop {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes button-pop {
|
||||
0% {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes reaction-pop {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
@keyframes wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(4deg);
|
||||
}
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-pop {
|
||||
animation: pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@utility animate-reaction-pop {
|
||||
animation: reaction-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@utility animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
@utility animate-wiggle {
|
||||
animation: wiggle 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
<link rel="canonical" href="{URL}" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="{ACCENT}" />
|
||||
<meta name="description" content="{DESCRIPTION}" />
|
||||
<meta property="og:url" content="{URL}" />
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
const h = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="cv col-3 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||
href={makeCalendarPath(url, getAddress(event))}>
|
||||
<Link class="cv col-3 card2 w-full cursor-pointer" href={makeCalendarPath(url, getAddress(event))}>
|
||||
<CalendarEventHeader {event} />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
<Button
|
||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||
class="center tooltip tooltip-left bg-primary text-primary-content absolute top-[7px] right-[7px] h-11 w-11 min-w-11 scale-90 rounded-full transition-transform motion-safe:hover:scale-100"
|
||||
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
||||
disabled={$uploading || disabled}
|
||||
onclick={submit}>
|
||||
<Icon icon={Plane} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {type Instance} from "tippy.js"
|
||||
import {formatTimestampAsTime} from "@welshman/lib"
|
||||
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {thunks, mergeThunks, pubkey, deriveProfileDisplay, sendWrapped} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
@@ -16,7 +16,7 @@
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
|
||||
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
|
||||
import {getColor} from "@app/theme"
|
||||
import {colors} from "@app/theme"
|
||||
import {makeDelete} from "@app/deletes"
|
||||
import {makeReaction} from "@app/reactions"
|
||||
import {pushModal} from "@app/modal"
|
||||
@@ -35,7 +35,7 @@
|
||||
const isOwn = event.pubkey === $pubkey
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
||||
const colorValue = getColor(event.pubkey)
|
||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||
|
||||
const reply = () => replyTo(event)
|
||||
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
|
||||
@@ -101,9 +101,7 @@
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
|
||||
<TapTarget
|
||||
class="chat-bubble shadow-soft mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px] {isOwn
|
||||
? 'bg-primary text-primary-content'
|
||||
: 'bg-base-100'}"
|
||||
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px]"
|
||||
onTap={showMobileMenu}>
|
||||
{#if showPubkey}
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -111,7 +109,7 @@
|
||||
<Button onclick={openProfile} class="flex items-center gap-1">
|
||||
<ProfileCircle
|
||||
pubkey={event.pubkey}
|
||||
style="box-shadow: 0 0 0 1.5px {colorValue}"
|
||||
class="border border-solid border-base-content"
|
||||
size={4} />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</script>
|
||||
|
||||
<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))}>
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
<span class="loading loading-spinner"></span>
|
||||
</div>
|
||||
{: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}
|
||||
<img
|
||||
alt=""
|
||||
@@ -92,7 +93,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{: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}
|
||||
</p>
|
||||
{/await}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {fromNostrURI} from "@welshman/util"
|
||||
import {nthEq} from "@welshman/lib"
|
||||
import {
|
||||
@@ -37,10 +38,11 @@
|
||||
interface Props {
|
||||
event: any
|
||||
trimParent?: boolean
|
||||
singleLine?: boolean
|
||||
url?: string
|
||||
}
|
||||
|
||||
const {event, trimParent = false, url}: Props = $props()
|
||||
const {event, trimParent = false, singleLine = false, url}: Props = $props()
|
||||
|
||||
const fullContent = parse(event)
|
||||
|
||||
@@ -104,10 +106,18 @@
|
||||
</p>
|
||||
</div>
|
||||
{: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}
|
||||
{#if isNewline(parsed)}
|
||||
<ContentNewline value={parsed.value} />
|
||||
{#if singleLine}
|
||||
{" "}
|
||||
{:else}
|
||||
<ContentNewline value={parsed.value} />
|
||||
{/if}
|
||||
{:else if isTopic(parsed)}
|
||||
<ContentTopic value={parsed.value} />
|
||||
{:else if isEmoji(parsed)}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import {deriveEvent} from "@app/repository"
|
||||
import {entityLink} from "@app/env"
|
||||
@@ -43,14 +44,20 @@
|
||||
|
||||
<Button class="my-2 block w-full max-w-full text-left" {onclick}>
|
||||
{#if $quote}
|
||||
{#if $quote.kind === MESSAGE}
|
||||
{#if $quote.content.trim().match(/^(nostr:)?nevent1[a-z0-9]+$/)}
|
||||
<NoteContent {url} event={$quote} />
|
||||
{:else if $quote.kind === MESSAGE}
|
||||
<div
|
||||
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
||||
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
|
||||
<NoteContentMinimal trimParent {url} event={$quote} />
|
||||
</div>
|
||||
{:else}
|
||||
<NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4">
|
||||
<NoteCard
|
||||
noShadow
|
||||
event={$quote}
|
||||
{url}
|
||||
class="border border-solid border-base-content/20 rounded-box p-4">
|
||||
<NoteContentMinimal {url} event={$quote} />
|
||||
</NoteCard>
|
||||
{/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,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
||||
type Props = {
|
||||
icon?: string
|
||||
title: string
|
||||
children?: Snippet
|
||||
action?: Snippet
|
||||
}
|
||||
|
||||
const {icon, title, children, action}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="m-auto flex max-w-sm flex-col items-center gap-3 px-4 py-12 text-center"
|
||||
in:fly={{y: 16}}>
|
||||
{#if icon}
|
||||
<div class="bg-primary/10 text-primary center size-16 rounded-full motion-safe:animate-float">
|
||||
<Icon {icon} size={8} />
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="font-display text-xl font-bold tracking-tight">{title}</h3>
|
||||
{#if children}
|
||||
<p class="text-sm opacity-70">{@render children?.()}</p>
|
||||
{/if}
|
||||
{#if action}
|
||||
<div class="mt-1">{@render action?.()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import {fly} from "@lib/transition"
|
||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||
import {publishComment} from "@app/comments"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {PROTECTED} from "@app/groups"
|
||||
import {PROTECTED, prependParent} from "@app/groups"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {pushToast} from "@app/toast"
|
||||
@@ -18,8 +20,17 @@
|
||||
content?: string | object
|
||||
}
|
||||
|
||||
const {url, event, onClose, onSubmit} = $props()
|
||||
const draftKey = new DraftKey<Values>(`reply:${event.id}`)
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
parent?: TrustedEvent
|
||||
onClose: () => void
|
||||
onClearParent?: () => void
|
||||
onSubmit: (thunk: unknown) => void
|
||||
}
|
||||
|
||||
const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props()
|
||||
const draftKey = new DraftKey<Values>(`reply:${event.id}:${parent?.id || ""}`)
|
||||
const initialValues = draftKey.get()
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const uploading = writable(false)
|
||||
@@ -31,8 +42,8 @@
|
||||
if ($uploading) return
|
||||
|
||||
const ed = await editor
|
||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
const tags = ed.storage.nostr.getEditorTags()
|
||||
let content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
let tags = ed.storage.nostr.getEditorTags()
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
@@ -45,6 +56,10 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
;({content, tags} = prependParent(parent, {content, tags}, url))
|
||||
}
|
||||
|
||||
draftKey.clear()
|
||||
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
||||
}
|
||||
@@ -87,6 +102,9 @@
|
||||
onsubmit={preventDefault(submit)}
|
||||
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
|
||||
<div class="card2 mx-2 my-2 bg-alt shadow-md">
|
||||
{#if parent}
|
||||
<ChatComposeParent event={parent} clear={() => onClearParent?.()} verb="Replying to" />
|
||||
{/if}
|
||||
<div class="relative">
|
||||
<div class="note-editor grow overflow-hidden">
|
||||
<EditorContent {autofocus} {editor} />
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
const h = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||
href={makeGoalPath(url, event.id)}>
|
||||
<Link class="cv col-2 card2 w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
|
||||
<p class="text-2xl">{event.content}</p>
|
||||
<Content
|
||||
event={{content: summary, tags: event.tags}}
|
||||
|
||||
@@ -80,9 +80,8 @@
|
||||
for={id}
|
||||
aria-label="Drag and drop files here."
|
||||
style="background-image: url({url});"
|
||||
class="avatar-blob relative flex h-24 w-24 shrink-0 cursor-pointer items-center justify-center border-2 border-dashed border-primary/40 bg-base-300 bg-cover bg-center transition-all motion-safe:hover:rotate-1 motion-safe:hover:scale-[1.02]"
|
||||
class="relative flex h-24 w-24 shrink-0 cursor-pointer items-center justify-center rounded-full border-2 border-solid border-base-content bg-base-300 bg-cover bg-center transition-all"
|
||||
class:transparent={!url}
|
||||
class:border-solid={url || active}
|
||||
class:border-primary={active}
|
||||
ondragenter={stopPropagation(preventDefault(onDragEnter))}
|
||||
ondragover={stopPropagation(preventDefault(onDragOver))}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import LogIn from "@app/components/LogIn.svelte"
|
||||
import SignUp from "@app/components/SignUp.svelte"
|
||||
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME, PLATFORM_LOGO} from "@app/env"
|
||||
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/env"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const logIn = () => pushModal(LogIn)
|
||||
@@ -19,15 +19,9 @@
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col items-center gap-3 py-2">
|
||||
<img
|
||||
src={PLATFORM_LOGO}
|
||||
alt={PLATFORM_NAME}
|
||||
class="shadow-soft ring-primary/20 size-16 rounded-2xl object-cover ring-4 motion-safe:animate-float" />
|
||||
<h1 class="heading">Welcome to <span class="brand">{PLATFORM_NAME}</span>!</h1>
|
||||
<p class="max-w-sm text-center opacity-80">
|
||||
A cozy home for your community — chat, connect, and own your little corner of the internet.
|
||||
</p>
|
||||
<div class="py-2">
|
||||
<h1 class="heading">Welcome to {PLATFORM_NAME}!</h1>
|
||||
<p class="text-center">The chat app built for self-hosted communities.</p>
|
||||
</div>
|
||||
<Button onclick={logIn}>
|
||||
<CardButton class="btn-primary">
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
onclick={() => selectAccount(option)}
|
||||
disabled={loading}
|
||||
class="card2 bg-alt flex w-full items-center p-3 text-left">
|
||||
<Profile pubkey={option.pubkey} />
|
||||
<Profile inert pubkey={option.pubkey} />
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<NoteCard {event} {url} class="cv card2 bg-alt">
|
||||
<NoteCard {event} {url} class="cv card2">
|
||||
<NoteContent {event} expandMode="inline" />
|
||||
<div class="flex w-full justify-between gap-2">
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</script>
|
||||
|
||||
<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}>
|
||||
<PrimaryNavSpaces />
|
||||
{#if PLATFORM_RELAYS.length > 0}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
|
||||
</script>
|
||||
|
||||
<div class="flex max-w-full items-start gap-3">
|
||||
<div class="flex max-w-full items-start gap-2">
|
||||
{#if inert}
|
||||
<span class="py-1">
|
||||
<ProfileCircle {pubkey} size={avatarSize} />
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if inert}
|
||||
<span class="text-bold overflow-hidden text-ellipsis">
|
||||
<span class="text-bold overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{$profileDisplay}
|
||||
</span>
|
||||
{:else}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {deriveProfile, deriveProfileDisplay} from "@welshman/app"
|
||||
import {getColor, getBlobVariant} from "@app/theme"
|
||||
import {deriveProfile} from "@welshman/app"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
|
||||
type Props = {
|
||||
@@ -10,39 +10,15 @@
|
||||
class?: string
|
||||
size?: number
|
||||
url?: string
|
||||
shape?: "blob" | "circle"
|
||||
style?: string
|
||||
}
|
||||
|
||||
const {pubkey, url, size = 7, shape = "blob", style = "", ...props}: Props = $props()
|
||||
const {pubkey, url, size = 7, ...props}: Props = $props()
|
||||
|
||||
const profile = deriveProfile(pubkey, removeUndefined([url]))
|
||||
const display = deriveProfileDisplay(pubkey)
|
||||
|
||||
// Organic, hand-drawn-feeling mask. The variant is stable per pubkey so a
|
||||
// person's silhouette never changes; `shape="circle"` opts back into a disc.
|
||||
const shapeClass =
|
||||
shape === "circle"
|
||||
? "rounded-full"
|
||||
: ["avatar-blob", "avatar-blob-2", "avatar-blob-3"][getBlobVariant(pubkey) - 1]
|
||||
|
||||
const color = getColor(pubkey)
|
||||
const px = $derived(size * 4)
|
||||
const initial = $derived([...($display || "")].find(c => c.trim()) || "?")
|
||||
</script>
|
||||
|
||||
{#if $profile?.picture}
|
||||
<ImageIcon {size} alt="" {style} class={cx(props.class, shapeClass)} src={$profile.picture} />
|
||||
{:else}
|
||||
<!-- Fallback: a subtle gradient derived from the pubkey + the person's initial. -->
|
||||
<div
|
||||
class={cx(
|
||||
props.class,
|
||||
shapeClass,
|
||||
"font-display flex shrink-0 items-center justify-center font-bold text-white uppercase select-none",
|
||||
)}
|
||||
style="width:{px}px;height:{px}px;font-size:{px *
|
||||
0.45}px;background-image:linear-gradient(135deg,{color},color-mix(in oklab,{color},#000 28%));{style}">
|
||||
{initial}
|
||||
</div>
|
||||
{/if}
|
||||
<ImageIcon
|
||||
{size}
|
||||
alt=""
|
||||
class={cx(props.class, "rounded-full")}
|
||||
src={$profile?.picture || UserRounded} />
|
||||
|
||||
@@ -14,14 +14,12 @@
|
||||
const {pubkeys, size = 7, limit, class: className}: Props = $props()
|
||||
const effectiveLimit = $derived(limit ?? (isMobile ? 7 : 10))
|
||||
|
||||
// circle is one step smaller than box so the bg-base-100 wrapper reads as a
|
||||
// thin separating ring between overlapping avatars (Discord-style stack).
|
||||
const dimensions = $derived(
|
||||
size <= 5
|
||||
? {box: "h-5 w-5", circle: 4, overlap: "-mr-2", overflow: "text-[9px]"}
|
||||
? {box: "h-5 w-5", overlap: "-mr-2", overflow: "text-[9px]"}
|
||||
: size <= 6
|
||||
? {box: "h-6 w-6", circle: 5, overlap: "-mr-2.5", overflow: "text-[10px]"}
|
||||
: {box: "h-8 w-8", circle: 7, overlap: "-mr-3", overflow: "text-xs"},
|
||||
? {box: "h-6 w-6", overlap: "-mr-2.5", overflow: "text-[10px]"}
|
||||
: {box: "h-8 w-8", overlap: "-mr-3", overflow: "text-xs"},
|
||||
)
|
||||
|
||||
for (const pubkey of pubkeys) {
|
||||
@@ -39,21 +37,20 @@
|
||||
</script>
|
||||
|
||||
<div class={cx("flex", size <= 5 ? "pr-2" : "pr-3", className)}>
|
||||
{#each displayPubkeys as pubkey, i (pubkey)}
|
||||
{#each displayPubkeys as pubkey (pubkey)}
|
||||
<div
|
||||
class={cx(
|
||||
"z-feature inline-flex items-center justify-center rounded-full bg-base-100 transition-transform",
|
||||
"z-feature inline-block flex items-center justify-center rounded-full bg-base-100",
|
||||
dimensions.box,
|
||||
dimensions.overlap,
|
||||
i % 2 === 0 ? "rotate-2" : "-rotate-2",
|
||||
)}>
|
||||
<ProfileCircle class="bg-base-300" shape="circle" {pubkey} size={dimensions.circle} />
|
||||
<ProfileCircle class={cx(dimensions.box, "bg-base-300")} {pubkey} {size} />
|
||||
</div>
|
||||
{/each}
|
||||
{#if overflowCount > 0}
|
||||
<div
|
||||
class={cx(
|
||||
"z-feature bg-primary text-primary-content shadow-soft font-display inline-flex rotate-2 items-center justify-center rounded-full font-bold",
|
||||
"z-feature inline-flex items-center justify-center rounded-full bg-neutral font-medium text-neutral-content",
|
||||
dimensions.box,
|
||||
dimensions.overlap,
|
||||
dimensions.overflow,
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {ManagementMethod} from "@welshman/util"
|
||||
import {
|
||||
manageRelay,
|
||||
deriveProfile,
|
||||
displayProfileByPubkey,
|
||||
loadMessagingRelayList,
|
||||
} from "@welshman/app"
|
||||
import {deriveProfile, displayProfileByPubkey, loadMessagingRelayList} from "@welshman/app"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||
@@ -29,7 +23,12 @@
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||
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 {pushToast} from "@app/toast"
|
||||
import {goToChat} from "@app/routes"
|
||||
@@ -68,10 +67,7 @@
|
||||
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],
|
||||
})
|
||||
const error = await banSpaceMembers(url!, [pubkey])
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
|
||||
export type Props = {
|
||||
pubkey: string
|
||||
singleLine?: boolean
|
||||
url?: string
|
||||
}
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
const {pubkey, url, singleLine}: Props = $props()
|
||||
|
||||
const profile = deriveProfile(pubkey, removeUndefined([url]))
|
||||
</script>
|
||||
|
||||
{#if $profile}
|
||||
<ContentMinimal event={{content: $profile.about || "", tags: []}} />
|
||||
<ContentMinimal event={{content: $profile.about || "", tags: []}} {singleLine} />
|
||||
{/if}
|
||||
|
||||
@@ -2,14 +2,8 @@
|
||||
const {current, total}: {current: number; total: number} = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex w-full gap-1.5">
|
||||
<div class="flex w-full">
|
||||
{#each Array(total) as _, i}
|
||||
<div
|
||||
class="h-2 flex-1 rounded-full transition-colors duration-300 {i < current
|
||||
? 'bg-primary'
|
||||
: i === current
|
||||
? 'bg-primary/40 motion-safe:animate-pulse'
|
||||
: 'bg-base-300'}">
|
||||
</div>
|
||||
<div class="h-1 flex-1 transition-colors {i < current ? 'bg-primary' : 'bg-base-300'}"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -157,15 +157,15 @@
|
||||
data-tip={tooltip}
|
||||
class={cx(
|
||||
reactionClass,
|
||||
"flex-inline btn btn-xs flex items-center gap-1 rounded-full border text-xs font-normal transition-transform motion-safe:hover:scale-110 motion-safe:active:scale-95",
|
||||
"flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal bg-alt",
|
||||
{
|
||||
tooltip: !noTooltip && !isMobile,
|
||||
"bg-alt border-base-content/15": !isOwn,
|
||||
"border-primary/50 bg-primary/15 text-primary": isOwn,
|
||||
"border-neutral-content/20": !isOwn,
|
||||
"btn-primary": isOwn,
|
||||
},
|
||||
)}>
|
||||
<Reaction event={zaps[0].request} />
|
||||
<span class="font-semibold">{amount}</span>
|
||||
<span>{amount}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each groupedReactions.entries() as [key, events]}
|
||||
@@ -179,17 +179,17 @@
|
||||
data-tip={tooltip}
|
||||
class={cx(
|
||||
reactionClass,
|
||||
"flex-inline btn btn-xs gap-1 rounded-full border font-normal transition-transform motion-safe:hover:scale-110 motion-safe:active:scale-95",
|
||||
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal bg-alt",
|
||||
{
|
||||
tooltip: !noTooltip && !isMobile,
|
||||
"bg-alt border-base-content/15": !isOwn,
|
||||
"border-primary/50 bg-primary/15 text-primary": isOwn,
|
||||
"border-neutral-content/20": !isOwn,
|
||||
"btn-primary": isOwn,
|
||||
},
|
||||
)}
|
||||
onclick={stopPropagation(preventDefault(onClick))}>
|
||||
<Reaction event={events[0]} />
|
||||
{#if events.length > 1}
|
||||
<span class="font-semibold">{events.length}</span>
|
||||
<span>{events.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
|
||||
import {getColor} from "@app/theme"
|
||||
import {makeRoomPath, makeSpaceChatPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
@@ -26,9 +25,7 @@
|
||||
const onClick = () => goto(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
|
||||
</script>
|
||||
|
||||
<Button
|
||||
class="cv card2 bg-alt shadow-soft block w-full transition-all motion-safe:hover:-translate-y-0.5"
|
||||
onclick={onClick}>
|
||||
<Button class="cv card2" onclick={onClick}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
{#if h}
|
||||
@@ -42,10 +39,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<ProfileCircle
|
||||
pubkey={event.pubkey}
|
||||
size={10}
|
||||
style="box-shadow: 0 0 0 2px {getColor(event.pubkey)}" />
|
||||
<ProfileCircle pubkey={event.pubkey} size={10} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<NoteContentMinimal {event} />
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import {getColor} from "@app/theme"
|
||||
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
|
||||
type Props = {
|
||||
@@ -13,22 +12,10 @@
|
||||
const {url, size = 7, ...props}: Props = $props()
|
||||
|
||||
const relay = deriveRelay(url)
|
||||
const px = size * 4
|
||||
const color = getColor(url)
|
||||
const letter = (url.replace(/^wss?:\/\//, "").replace(/^www\./, "")[0] || "?").toUpperCase()
|
||||
</script>
|
||||
|
||||
{#if $relay?.icon}
|
||||
<ImageIcon {size} alt="" src={$relay.icon} class={cx(props.class, "squircle")} />
|
||||
<ImageIcon {size} alt="" src={$relay?.icon} class={props.class} />
|
||||
{:else}
|
||||
<!-- Lettered workspace tile (Slack/Discord-style) colored by the relay url. -->
|
||||
<div
|
||||
class={cx(
|
||||
props.class,
|
||||
"squircle font-display flex shrink-0 items-center justify-center font-bold text-white uppercase",
|
||||
)}
|
||||
style="width:{px}px;height:{px}px;font-size:{px *
|
||||
0.42}px;background-image:linear-gradient(135deg,{color},color-mix(in oklab,{color},#000 28%))">
|
||||
{letter}
|
||||
</div>
|
||||
<ImageIcon size={size - 2} alt="" src={RemoteControllerMinimalistic} class={props.class} />
|
||||
{/if}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import Button from "@lib/components/Button.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 {canEnforceNip70} from "@app/relays"
|
||||
import {pushToast} from "@app/toast"
|
||||
@@ -91,10 +91,7 @@
|
||||
title: "Ban User",
|
||||
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
|
||||
confirm: async () => {
|
||||
const {error} = await manageRelay(url, {
|
||||
method: ManagementMethod.BanPubkey,
|
||||
params: [pubkey, reason],
|
||||
})
|
||||
const error = await banSpaceMembers(url, [pubkey], reason)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
|
||||
+27
-16
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
@@ -12,43 +11,55 @@
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.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"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
role: SpaceRole
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
const {url, role}: Props = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const addMember = async () => {
|
||||
let loading = $state(false)
|
||||
let pubkeys: string[] = $state([])
|
||||
|
||||
const submit = async () => {
|
||||
loading = true
|
||||
|
||||
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) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Members have successfully been added!"})
|
||||
back()
|
||||
if (memberError) {
|
||||
pushToast({theme: "error", message: memberError})
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
let pubkeys: string[] = $state([])
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Add Members</ModalTitle>
|
||||
<ModalSubtitle>to {displayRelayUrl(url)}</ModalSubtitle>
|
||||
<ModalTitle>Add to {role.label || "Role"}</ModalTitle>
|
||||
<ModalSubtitle>Assign members to this role</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
@@ -64,7 +75,7 @@
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</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>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -135,7 +135,7 @@
|
||||
</div>
|
||||
<Button
|
||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||
class="center tooltip tooltip-left bg-primary text-primary-content absolute top-[7px] right-[7px] h-11 w-11 min-w-11 scale-90 rounded-full transition-transform motion-safe:hover:scale-100"
|
||||
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
||||
disabled={$uploading}
|
||||
onclick={submit}>
|
||||
<Icon icon={Plane} />
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Volume from "@assets/icons/volume.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import {getColor} from "@app/theme"
|
||||
import {deriveRoom} from "@app/groups"
|
||||
|
||||
interface Props {
|
||||
@@ -17,26 +16,18 @@
|
||||
|
||||
const room = deriveRoom(url, h)
|
||||
const isVoiceRoom = $derived($room.livekit)
|
||||
const px = size * 4
|
||||
// Voice rooms read warm/orange; text rooms get a per-room identity color.
|
||||
const color = $derived(isVoiceRoom ? "var(--color-secondary)" : getColor(h))
|
||||
</script>
|
||||
|
||||
{#if isVoiceRoom}
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<Icon size={size + 1} icon={Volume} class="text-secondary" />
|
||||
<Icon size={size + 1} icon={Volume} />
|
||||
{#if $room.picture}
|
||||
<span class="text-base">/</span>
|
||||
<ImageIcon src={$room.picture} {size} alt="" class="squircle shadow-sm" />
|
||||
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if $room.picture}
|
||||
<ImageIcon src={$room.picture} {size} alt="" class="squircle shadow-sm" />
|
||||
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
|
||||
{:else}
|
||||
<!-- Colored room tile with the type glyph in white. -->
|
||||
<div
|
||||
class="squircle flex shrink-0 items-center justify-center text-white"
|
||||
style="width:{px}px;height:{px}px;background-image:linear-gradient(135deg,{color},color-mix(in oklab,{color},#000 28%))">
|
||||
<Icon icon={fallbackIcon} size={Math.max(3, size - 1)} />
|
||||
</div>
|
||||
<Icon icon={fallbackIcon} {size} />
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {readable} from "svelte/store"
|
||||
import {gte, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
|
||||
import {
|
||||
hash,
|
||||
gte,
|
||||
now,
|
||||
displayList,
|
||||
formatTimestampAsTime,
|
||||
formatTimestampAsDate,
|
||||
} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {MESSAGE, COMMENT, getTag} from "@welshman/util"
|
||||
import {
|
||||
@@ -28,7 +35,7 @@
|
||||
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
|
||||
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
|
||||
import RoomItemContent from "@app/components/RoomItemContent.svelte"
|
||||
import {getColor} from "@app/theme"
|
||||
import {colors} from "@app/theme"
|
||||
import {ENABLE_ZAPS} from "@app/env"
|
||||
import {deriveEventsForUrl, deriveEvent} from "@app/repository"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
@@ -53,7 +60,7 @@
|
||||
const today = formatTimestampAsDate(now())
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
||||
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
||||
const colorValue = getColor(event.pubkey)
|
||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||
|
||||
const qTag = getTag("q", event.tags)
|
||||
const isQuoteOnly = Boolean(
|
||||
@@ -88,7 +95,10 @@
|
||||
<div class="flex w-full gap-3 overflow-auto">
|
||||
{#if showPubkey}
|
||||
<Button onclick={openProfile} class="flex items-start pt-1.5 justify-center w-8 shrink-0">
|
||||
<ProfileCircle pubkey={event.pubkey} style="box-shadow: 0 0 0 2px {colorValue}" size={8} />
|
||||
<ProfileCircle
|
||||
pubkey={event.pubkey}
|
||||
class="border border-solid border-base-content"
|
||||
size={8} />
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="w-8 shrink-0"></div>
|
||||
@@ -145,9 +155,8 @@
|
||||
</div>
|
||||
{#if !isMobile}
|
||||
<button
|
||||
class="join bg-base-100 shadow-soft absolute right-2 top-0.5 translate-y-1 rounded-full p-0.5 text-xs opacity-0 transition-all"
|
||||
class:group-hover:opacity-100={!isMobile}
|
||||
class:group-hover:translate-y-0={!isMobile}>
|
||||
class="join absolute right-2 top-0.5 border border-solid border-neutral text-xs opacity-0 transition-all pr-2"
|
||||
class:group-hover:opacity-100={!isMobile}>
|
||||
{#if ENABLE_ZAPS}
|
||||
<RoomItemZapButton {url} {event} />
|
||||
{/if}
|
||||
|
||||
@@ -95,19 +95,17 @@
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<h1 class="heading">Join <span class="brand">{PLATFORM_NAME}</span></h1>
|
||||
<h1 class="heading">Join {PLATFORM_NAME}</h1>
|
||||
<p class="m-auto max-w-sm text-center">
|
||||
Censorship resistant digital spaces for communities. Meet new people, own your identity.
|
||||
</p>
|
||||
{#if hasPomade}
|
||||
<Button onclick={flows.email.start} class="btn btn-primary rounded-full">
|
||||
<Button onclick={flows.email.start} class="btn btn-primary">
|
||||
<Icon icon={Letter} />
|
||||
Sign up with email
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
onclick={flows.nostr.start}
|
||||
class="btn rounded-full {hasPomade ? 'btn-neutral' : 'btn-primary'}">
|
||||
<Button onclick={flows.nostr.start} class="btn {hasPomade ? 'btn-neutral' : 'btn-primary'}">
|
||||
<Icon icon={Key} />
|
||||
Generate a key
|
||||
</Button>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.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 ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ProgressBar from "@app/components/ProgressBar.svelte"
|
||||
|
||||
@@ -23,12 +24,9 @@
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(next)}>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="center bg-primary/15 text-primary size-16 rounded-full motion-safe:animate-pop">
|
||||
<Icon icon={CheckCircle} size={9} />
|
||||
</div>
|
||||
<h1 class="heading">You're all set!</h1>
|
||||
</div>
|
||||
<ModalHeader>
|
||||
<ModalTitle>You're all set!</ModalTitle>
|
||||
</ModalHeader>
|
||||
<p>
|
||||
You've created your profile, saved your keys, and now you're ready to start chatting — all
|
||||
without asking permission!
|
||||
|
||||
@@ -29,20 +29,22 @@
|
||||
<Button onclick={back} class="place-self-start pr-3 md:hidden">
|
||||
<Icon icon={ArrowLeft} size={7} />
|
||||
</Button>
|
||||
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-2 items-center">
|
||||
<RelayIcon {url} size={5} class="rounded-full md:hidden" />
|
||||
<div class="hidden md:contents">
|
||||
<div class="flex grow items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex min-w-0 items-start gap-2">
|
||||
<RelayIcon {url} size={5} class="shrink-0 rounded-full md:hidden" />
|
||||
<div class="hidden shrink-0 md:flex md:items-center place-self-center">
|
||||
{@render leading?.()}
|
||||
</div>
|
||||
{@render title?.()}
|
||||
<div class="min-w-0">
|
||||
{@render title?.()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-primary md:hidden">
|
||||
<div class="text-xs text-primary pl-7 md:hidden">
|
||||
{displayRelayUrl(url)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{@render action?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -126,7 +126,7 @@
|
||||
<Modal tag="form" onsubmit={preventDefault(trySubmit)}>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Edit a Space</ModalTitle>
|
||||
<ModalTitle>Edit this Space</ModalTitle>
|
||||
<ModalSubtitle><span class="text-primary">{displayRelayUrl(url)}</span></ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<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}
|
||||
@@ -2,29 +2,36 @@
|
||||
import {onMount} from "svelte"
|
||||
import {sleep} from "@welshman/lib"
|
||||
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 LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Upload from "@assets/icons/upload.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 Field from "@lib/components/Field.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Divider from "@lib/components/Divider.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 ProfileMultiSelect from "@app/components/ProfileMultiSelect.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 {deriveRelayAuthError} from "@app/relays"
|
||||
import {deriveRelayAuthError, deriveSupportedMethods} from "@app/relays"
|
||||
import {addSpaceMembers} from "@app/members"
|
||||
|
||||
const {url} = $props()
|
||||
|
||||
const supportedMethods = deriveSupportedMethods(url)
|
||||
const canAddMembers = $derived($supportedMethods.includes(ManagementMethod.AllowPubkey))
|
||||
const authError = deriveRelayAuthError(url)
|
||||
|
||||
let networkError = $state(false)
|
||||
const isExplicitAuthError = $derived(
|
||||
$authError &&
|
||||
@@ -54,6 +61,28 @@
|
||||
let loading = $state(true)
|
||||
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(() => {
|
||||
const relay = displayRelayUrl(url)
|
||||
const params = new URLSearchParams({r: relay, c: claim}).toString()
|
||||
@@ -124,7 +153,7 @@
|
||||
<div class="flex w-full gap-2">
|
||||
{#if canShare}
|
||||
<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}>
|
||||
<Icon icon={Upload} />
|
||||
</Button>
|
||||
@@ -152,8 +181,32 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
<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>
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {debounce} from "throttle-debounce"
|
||||
import {dissoc, maybe} from "@welshman/lib"
|
||||
import {goto} from "$app/navigation"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {slideAndFade} from "@lib/transition"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
@@ -22,7 +21,7 @@
|
||||
import RelaySummary from "@app/components/RelaySummary.svelte"
|
||||
import SpaceJoinSettings from "@app/components/SpaceJoinSettings.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {goToSpace} from "@app/routes"
|
||||
import {relaysMostlyRestricted} from "@app/policies"
|
||||
import {notificationSettings, setSpaceNotifications} from "@app/settings"
|
||||
import {parseInviteLink} from "@app/invites"
|
||||
@@ -68,7 +67,7 @@
|
||||
}
|
||||
|
||||
await addSpace(url)
|
||||
await goto(makeSpacePath(url), {replaceState: true})
|
||||
await goToSpace(url)
|
||||
|
||||
broadcastUserData([url])
|
||||
relaysMostlyRestricted.update(dissoc(url))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import {dissoc, maybe} from "@welshman/lib"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
@@ -22,7 +21,7 @@
|
||||
import {notificationSettings} from "@app/settings"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {goToSpace} from "@app/routes"
|
||||
import {Push} from "@app/push"
|
||||
|
||||
type Props = {
|
||||
@@ -56,7 +55,7 @@
|
||||
}
|
||||
|
||||
await addSpace(url)
|
||||
await goto(makeSpacePath(url), {replaceState: true})
|
||||
await goToSpace(url)
|
||||
|
||||
broadcastUserData([url])
|
||||
relaysMostlyRestricted.update(dissoc(url))
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
</ModalHeader>
|
||||
<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)}
|
||||
<div class="card2 bg-alt relative">
|
||||
<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>
|
||||
@@ -5,8 +5,8 @@
|
||||
import {fly} from "@lib/transition"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||
import Home from "@assets/icons/home.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
||||
@@ -29,12 +29,10 @@
|
||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
|
||||
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
||||
import SpaceDetail from "@app/components/SpaceDetail.svelte"
|
||||
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
||||
import SpaceExit from "@app/components/SpaceExit.svelte"
|
||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import SpaceMembers from "@app/components/SpaceMembers.svelte"
|
||||
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
|
||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
|
||||
@@ -42,7 +40,7 @@
|
||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||
import {ENABLE_ZAPS} from "@app/env"
|
||||
import {CONTENT_KINDS} from "@app/content"
|
||||
import {deriveSpaceMembers, deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members"
|
||||
import {deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members"
|
||||
import {
|
||||
deriveUserRooms,
|
||||
deriveOtherRooms,
|
||||
@@ -70,7 +68,6 @@
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||
const members = deriveSpaceMembers(url)
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
const actionItems = deriveSpaceActionItems(url)
|
||||
|
||||
@@ -97,10 +94,6 @@
|
||||
showMenu = !showMenu
|
||||
}
|
||||
|
||||
const showDetail = () => pushModal(SpaceDetail, {url})
|
||||
|
||||
const showMembers = () => pushModal(SpaceMembers, {url})
|
||||
|
||||
const showActionItems = () => pushModal(SpaceActionItems, {url})
|
||||
|
||||
const canCreateRoom = deriveUserCanCreateRoom(url)
|
||||
@@ -139,7 +132,7 @@
|
||||
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
||||
onclick={openMenu}>
|
||||
<div class="flex items-center justify-between">
|
||||
<strong class="font-display relative flex items-center gap-1">
|
||||
<strong class="flex items-center gap-1 relative">
|
||||
<RelayName {url} class="ellipsize" />
|
||||
<div
|
||||
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
||||
@@ -164,22 +157,6 @@
|
||||
Create Invite
|
||||
</Button>
|
||||
</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}
|
||||
<li>
|
||||
<Button onclick={showActionItems}>
|
||||
@@ -230,6 +207,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
<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)}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
|
||||
<Icon icon={History} /> Recent Activity
|
||||
@@ -239,6 +219,9 @@
|
||||
<Icon icon={ChatRound} /> Chat
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
|
||||
<Icon icon={UsersGroup} /> Directory
|
||||
</SecondaryNavItem>
|
||||
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
|
||||
<SecondaryNavItem href={goalsPath}>
|
||||
<Icon icon={StarFallMinimalistic} /> Goals
|
||||
@@ -311,8 +294,8 @@
|
||||
<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">
|
||||
<VoiceWidget />
|
||||
<Button class="btn btn-ghost btn-sm bg-base-100 h-10 rounded-full" onclick={showDetail}>
|
||||
<Link href={makeSpacePath("about")} class="btn btn-neutral btn-sm h-10">
|
||||
<SocketStatusIndicator {url} />
|
||||
</Button>
|
||||
</Link>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import ThreadBoardItem from "@app/components/ThreadBoardItem.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h: string
|
||||
threads: TrustedEvent[]
|
||||
}
|
||||
|
||||
const {url, h, threads}: Props = $props()
|
||||
</script>
|
||||
|
||||
<section class="overflow-hidden rounded-box border border-base-content/15 bg-base-100 shadow-sm">
|
||||
<header
|
||||
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">
|
||||
{#if h}
|
||||
#<RoomName {url} {h} />
|
||||
{:else}
|
||||
General
|
||||
{/if}
|
||||
</h2>
|
||||
<span class="text-xs opacity-60">
|
||||
{threads.length}
|
||||
{threads.length === 1 ? "topic" : "topics"}
|
||||
</span>
|
||||
</header>
|
||||
<div
|
||||
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">
|
||||
<span>Topic</span>
|
||||
<span>Author</span>
|
||||
<span class="text-center">Replies</span>
|
||||
<span class="text-right">Last post</span>
|
||||
</div>
|
||||
{#each threads as event (event.id)}
|
||||
<ThreadBoardItem {url} {event} />
|
||||
{/each}
|
||||
</section>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import {formatTimestamp, max} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT, getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {makeThreadPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
||||
const replies = deriveEventsForUrl(url, filters)
|
||||
const replyCount = $derived($replies.length)
|
||||
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
||||
const title = getTagValue("title", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
href={makeThreadPath(url, event.id)}
|
||||
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">
|
||||
<div class="col-span-2 min-w-0 sm:col-span-1">
|
||||
<p class="ellipsize text-sm font-bold sm:text-base">{title || "Untitled thread"}</p>
|
||||
<p class="ellipsize mt-0.5 text-xs opacity-60 sm:hidden">
|
||||
by <ProfileName pubkey={event.pubkey} {url} />
|
||||
</p>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 sm:flex">
|
||||
<ProfileCircle pubkey={event.pubkey} {url} size={6} />
|
||||
<span class="ellipsize text-sm">
|
||||
<ProfileName pubkey={event.pubkey} {url} />
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-right text-xs opacity-75 sm:text-center sm:text-sm">
|
||||
<span class="opacity-60 sm:hidden">Replies · </span>
|
||||
{replyCount}
|
||||
</p>
|
||||
<p class="text-right text-xs opacity-75 sm:text-sm">
|
||||
<span class="opacity-60 sm:hidden">Last · </span>
|
||||
{formatTimestamp(lastActive)}
|
||||
</p>
|
||||
</Link>
|
||||
@@ -20,9 +20,7 @@
|
||||
const h = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-xl"
|
||||
href={makeThreadPath(url, event.id)}>
|
||||
<Link class="cv col-2 card2 w-full cursor-pointer" href={makeThreadPath(url, event.id)}>
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<p class="text-xl">{title}</p>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import DoubleAltArrowLeft from "@assets/icons/double-alt-arrow-left.svg?dataurl"
|
||||
import DoubleAltArrowRight from "@assets/icons/double-alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
||||
type Props = {
|
||||
page: number
|
||||
pageCount: number
|
||||
onPage: (page: number) => void
|
||||
}
|
||||
|
||||
const {page, pageCount, onPage}: Props = $props()
|
||||
|
||||
const goFirst = () => onPage(1)
|
||||
const goPrev = () => onPage(page - 1)
|
||||
const goNext = () => onPage(page + 1)
|
||||
const goLast = () => onPage(pageCount)
|
||||
const goToPage = (target: number) => onPage(target)
|
||||
|
||||
const pages = $derived.by(() => {
|
||||
if (pageCount <= 7) {
|
||||
return Array.from({length: pageCount}, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const result = new Set<number>([1, pageCount, page])
|
||||
|
||||
if (page > 2) result.add(page - 1)
|
||||
if (page < pageCount - 1) result.add(page + 1)
|
||||
if (page > 3) result.add(page - 2)
|
||||
if (page < pageCount - 2) result.add(page + 2)
|
||||
|
||||
return Array.from(result).sort((a, b) => a - b)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-3 border-t border-base-content/10 py-4">
|
||||
<p class="text-sm opacity-75">Page {page} of {pageCount}</p>
|
||||
<div class="join">
|
||||
<Button class="btn join-item btn-sm" disabled={page <= 1} onclick={goFirst}>
|
||||
<Icon icon={DoubleAltArrowLeft} size={4} />
|
||||
</Button>
|
||||
<Button class="btn join-item btn-sm" disabled={page <= 1} onclick={goPrev}>
|
||||
<Icon icon={AltArrowLeft} size={4} />
|
||||
</Button>
|
||||
{#each pages as p, i (p)}
|
||||
{#if i > 0 && p - pages[i - 1] > 1}
|
||||
<Button class="btn join-item btn-sm btn-disabled" disabled>…</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class={cx("btn join-item btn-sm", page === p && "btn-primary")}
|
||||
onclick={() => goToPage(p)}>
|
||||
{p}
|
||||
</Button>
|
||||
{/each}
|
||||
<Button class="btn join-item btn-sm" disabled={page >= pageCount} onclick={goNext}>
|
||||
<Icon icon={AltArrowRight} size={4} />
|
||||
</Button>
|
||||
<Button class="btn join-item btn-sm" disabled={page >= pageCount} onclick={goLast}>
|
||||
<Icon icon={DoubleAltArrowRight} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import {deriveHandleForPubkey, deriveProfileDisplay, displayHandle} from "@welshman/app"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import CommentActions from "@app/components/CommentActions.svelte"
|
||||
import ThreadActions from "@app/components/ThreadActions.svelte"
|
||||
import {makeEventPermalink} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {clip} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
threadPubkey: string
|
||||
onReply: (event: TrustedEvent) => void
|
||||
}
|
||||
|
||||
const {url, event, threadPubkey, onReply}: Props = $props()
|
||||
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
||||
const handle = deriveHandleForPubkey(event.pubkey)
|
||||
const isOp = event.pubkey === threadPubkey
|
||||
const isComment = event.kind === COMMENT
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
|
||||
|
||||
const copyPermalink = () => clip(makeEventPermalink(event, url))
|
||||
|
||||
const reply = () => onReply(event)
|
||||
</script>
|
||||
|
||||
<article
|
||||
id="post-{event.id}"
|
||||
data-event={event.id}
|
||||
class="border-b border-base-content/15 bg-base-100">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<aside
|
||||
class="flex shrink-0 flex-row items-center gap-3 border-b border-base-content/10 bg-base-200/50 p-3 md:w-40 md:flex-col md:items-center md:border-b-0 md:border-r md:p-4 md:text-center">
|
||||
<Button onclick={openProfile}>
|
||||
<ProfileCircle pubkey={event.pubkey} {url} size={10} class="md:size-14" />
|
||||
</Button>
|
||||
<div class="flex min-w-0 flex-col gap-1 md:items-center">
|
||||
<Button onclick={openProfile} class="text-bold ellipsize text-sm">
|
||||
{$profileDisplay}
|
||||
</Button>
|
||||
{#if $handle}
|
||||
<span class="ellipsize text-xs opacity-75">{displayHandle($handle)}</span>
|
||||
{/if}
|
||||
{#if isOp}
|
||||
<span class="badge badge-primary badge-sm">OP</span>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="flex min-w-0 grow flex-col">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-b border-base-content/10 bg-base-200/40 px-3 py-2 text-xs sm:px-4 sm:text-sm">
|
||||
<span class="opacity-75">{formatTimestamp(event.created_at)}</span>
|
||||
<Button class="btn btn-ghost btn-xs h-auto min-h-0 gap-1 px-1 py-0" onclick={copyPermalink}>
|
||||
<Icon icon={LinkRound} size={3} />
|
||||
Permalink
|
||||
</Button>
|
||||
</div>
|
||||
<div class="px-3 py-4 sm:px-4">
|
||||
{#if isComment}
|
||||
<Content showEntire {event} {url} />
|
||||
{:else}
|
||||
<NoteContent showEntire {event} {url} />
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 flex-col gap-2 border-t border-base-content/10 bg-base-200/20 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-4">
|
||||
<Button class="btn btn-neutral btn-xs w-fit gap-1" onclick={reply}>
|
||||
<Icon icon={Reply} size={4} />
|
||||
Reply
|
||||
</Button>
|
||||
{#if isComment}
|
||||
<CommentActions segment="threads" {event} {url} />
|
||||
{:else}
|
||||
<ThreadActions {event} {url} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -2,8 +2,6 @@
|
||||
import type {AbstractThunk} from "@welshman/app"
|
||||
import {thunkHasStatus, thunkIsComplete} from "@welshman/app"
|
||||
import {PublishStatus} from "@welshman/net"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ThunkPending from "@app/components/ThunkPending.svelte"
|
||||
import type {Toast} from "@app/toast"
|
||||
import {popToast} from "@app/toast"
|
||||
@@ -37,8 +35,5 @@
|
||||
{#if !isComplete}
|
||||
<ThunkPending {thunk} />
|
||||
{:else if !isFailure}
|
||||
<p class="flex items-center gap-1.5 text-xs opacity-80">
|
||||
<Icon icon={CheckCircle} size={4} class="text-success motion-safe:animate-pop" />
|
||||
Message sent!
|
||||
</p>
|
||||
<p class="text-xs opacity-75">Message sent!</p>
|
||||
{/if}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
url?: string
|
||||
pubkey: string
|
||||
eventId?: string
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
import {clip, pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
url?: string
|
||||
pubkey: string
|
||||
eventId?: string
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({
|
||||
...extra,
|
||||
})
|
||||
|
||||
export const makeDeleteFilter = (kinds: number[], extra: Filter = {}) => ({
|
||||
kinds: [DELETE],
|
||||
"#k": kinds.map(String),
|
||||
...extra,
|
||||
})
|
||||
|
||||
export const REPOST_KINDS = [REPOST, GENERIC_REPOST]
|
||||
|
||||
export const REACTION_KINDS = [REPORT, DELETE, REACTION]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+4
-4
@@ -18,7 +18,7 @@ import {
|
||||
now,
|
||||
on,
|
||||
sortBy,
|
||||
WEEK,
|
||||
MONTH,
|
||||
YEAR,
|
||||
} from "@welshman/lib"
|
||||
import {
|
||||
@@ -122,7 +122,7 @@ export const makeFeed = ({
|
||||
const controller = new AbortController()
|
||||
const events = writable<TrustedEvent[]>([])
|
||||
|
||||
let interval = int(WEEK)
|
||||
let interval = int(MONTH)
|
||||
let buffer = sortEventsDesc(getEventsForUrl(url, filters))
|
||||
let backwardWindow = [at - interval, at]
|
||||
let forwardWindow = [at, at + interval]
|
||||
@@ -213,7 +213,7 @@ export const makeFeed = ({
|
||||
if (events.length === 0) {
|
||||
interval = Math.round(interval * 1.1)
|
||||
} else {
|
||||
interval = int(WEEK)
|
||||
interval = int(MONTH)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ export const makeCalendarFeed = ({
|
||||
element: HTMLElement
|
||||
onExhausted?: () => void
|
||||
}) => {
|
||||
const interval = int(5, WEEK)
|
||||
const interval = int(5, MONTH)
|
||||
const controller = new AbortController()
|
||||
|
||||
let exhaustedScrollers = 0
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ROOM_MEMBERS,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
getPubkeyTagValues,
|
||||
getTags,
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
sortEventsAsc,
|
||||
@@ -23,11 +24,136 @@ import {first, memoize, sortBy, spec, uniq} from "@welshman/lib"
|
||||
import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app"
|
||||
import {get} from "svelte/store"
|
||||
import {deriveEventsForUrl, deriveRelaySignedEvents} from "@app/repository"
|
||||
|
||||
export const deriveSpaceMembers = (url: string) =>
|
||||
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) =>
|
||||
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) => {
|
||||
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 (
|
||||
url: string,
|
||||
room: PublishedRoomMeta,
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content"
|
||||
import {notificationSettings, shouldNotify, userSettingsValues} from "@app/settings"
|
||||
import {userSpaceUrls} from "@app/groups"
|
||||
import {makeSpacePath, getEventPath} from "@app/routes"
|
||||
import {getEventPath, goToSpace} from "@app/routes"
|
||||
|
||||
export type PushSubscription = {
|
||||
key: string
|
||||
@@ -111,7 +111,7 @@ export const onPushNotificationAction = async (action: ActionPerformed) => {
|
||||
if (event) {
|
||||
goto(await getEventPath(event, [relay]))
|
||||
} else {
|
||||
goto(makeSpacePath(relay))
|
||||
goToSpace(relay)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
+13
-2
@@ -20,7 +20,7 @@ import {
|
||||
getRelaysFromList,
|
||||
} from "@welshman/util"
|
||||
import {makeChatId} from "@app/chats"
|
||||
import {entityLink} from "@app/env"
|
||||
import {entityLink, PLATFORM_URL} from "@app/env"
|
||||
import {encodeRelay, hasNip29} from "@app/relays"
|
||||
import {DM_KINDS} from "@app/content"
|
||||
import {ROOM} from "@app/groups"
|
||||
@@ -87,7 +87,7 @@ export const goToSpace = async (url: string) => {
|
||||
} 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})
|
||||
goto(makeSpacePath(url, "about"), {replaceState: true})
|
||||
} else {
|
||||
goto(makeSpacePath(url), {replaceState: true})
|
||||
}
|
||||
@@ -211,6 +211,17 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
|
||||
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
|
||||
}
|
||||
|
||||
export const makeEventPermalink = (event: TrustedEvent, url?: string) => {
|
||||
const urls = url ? [url] : Array.from(tracker.getRelays(event.id))
|
||||
const path = getEventPath(event, urls)
|
||||
|
||||
if (path.includes("://")) {
|
||||
return path
|
||||
}
|
||||
|
||||
return `${PLATFORM_URL}${path}#${nip19.neventEncode({id: event.id, relays: urls})}`
|
||||
}
|
||||
|
||||
export const getRoomItemPath = (url: string, event: TrustedEvent) => {
|
||||
switch (event.kind) {
|
||||
case THREAD:
|
||||
|
||||
+6
-2
@@ -20,6 +20,7 @@ import {
|
||||
RELAY_REMOVE_MEMBER,
|
||||
MESSAGE,
|
||||
POLL_RESPONSE,
|
||||
APP_DATA,
|
||||
isSignedEvent,
|
||||
unionFilters,
|
||||
} from "@welshman/util"
|
||||
@@ -53,6 +54,8 @@ import {
|
||||
} from "@app/groups"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
import {loadFeedsForPubkey} from "@app/feeds"
|
||||
import {RELAY_ROLE} from "@app/members"
|
||||
import {FEATURED_CONTENT_D} from "@app/featured"
|
||||
import {hasBlossomSupport} from "@app/uploads"
|
||||
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
|
||||
|
||||
@@ -268,7 +271,7 @@ const syncUserData = () => {
|
||||
const syncSpace = (url: string) => {
|
||||
const since = ago(WEEK)
|
||||
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 roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE]
|
||||
|
||||
@@ -277,8 +280,9 @@ const syncSpace = (url: string) => {
|
||||
signal: controller.signal,
|
||||
filters: [
|
||||
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
|
||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||
{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]},
|
||||
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
|
||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
+17
-28
@@ -1,40 +1,29 @@
|
||||
import twColors from "tailwindcss/colors"
|
||||
import {hash} from "@welshman/lib"
|
||||
import {kv} from "@app/storage"
|
||||
import {synced} from "@welshman/store"
|
||||
|
||||
// Per-identity color palette, biased toward warm, saturated, brand-adjacent
|
||||
// hues so each person/space is recognizable at a glance. Deduped (was two
|
||||
// `sky` entries) and trimmed of low-distinctness gray (`zinc`). The 600 weight
|
||||
// reads clearly on both the warm-paper light base and the warm-charcoal dark
|
||||
// base. Each entry is [name, hex].
|
||||
export const colors = [
|
||||
["purple", twColors.purple[600]],
|
||||
["violet", twColors.violet[600]],
|
||||
["indigo", twColors.indigo[600]],
|
||||
["fuchsia", twColors.fuchsia[600]],
|
||||
["pink", twColors.pink[600]],
|
||||
["rose", twColors.rose[600]],
|
||||
["red", twColors.red[600]],
|
||||
["orange", twColors.orange[600]],
|
||||
["amber", twColors.amber[600]],
|
||||
["yellow", twColors.yellow[600]],
|
||||
["lime", twColors.lime[600]],
|
||||
["green", twColors.green[600]],
|
||||
["emerald", twColors.emerald[600]],
|
||||
["teal", twColors.teal[600]],
|
||||
["cyan", twColors.cyan[600]],
|
||||
["sky", twColors.sky[600]],
|
||||
["blue", twColors.blue[600]],
|
||||
["cyan", twColors.cyan[600]],
|
||||
["emerald", twColors.emerald[600]],
|
||||
["fuchsia", twColors.fuchsia[600]],
|
||||
["green", twColors.green[600]],
|
||||
["indigo", twColors.indigo[600]],
|
||||
["sky", twColors.sky[600]],
|
||||
["lime", twColors.lime[600]],
|
||||
["orange", twColors.orange[600]],
|
||||
["pink", twColors.pink[600]],
|
||||
["purple", twColors.purple[600]],
|
||||
["red", twColors.red[600]],
|
||||
["rose", twColors.rose[600]],
|
||||
["sky", twColors.sky[600]],
|
||||
["teal", twColors.teal[600]],
|
||||
["violet", twColors.violet[600]],
|
||||
["yellow", twColors.yellow[600]],
|
||||
["zinc", twColors.zinc[600]],
|
||||
]
|
||||
|
||||
// Single source of truth for per-pubkey identity color — reused by username
|
||||
// text, avatar gradient fallbacks, and avatar rings. Deterministic per pubkey.
|
||||
export const getColor = (pubkey = "") => colors[hash(pubkey) % colors.length][1]
|
||||
|
||||
// Which blob variant (1-3) a pubkey gets, so an avatar's organic shape is stable.
|
||||
export const getBlobVariant = (pubkey = "") => (hash(pubkey) % 3) + 1
|
||||
|
||||
export const theme = synced({
|
||||
key: "theme",
|
||||
defaultValue: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
"aria-pressed"?: boolean
|
||||
} = $props()
|
||||
|
||||
const className = $derived(
|
||||
`text-left cursor-pointer motion-safe:transition-transform motion-safe:duration-150 motion-safe:active:scale-[0.97] ${restProps.class}`,
|
||||
)
|
||||
const className = $derived(`text-left cursor-pointer ${restProps.class}`)
|
||||
|
||||
const onClick = (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -11,25 +11,21 @@
|
||||
const {...props}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group btn rounded-box shadow-soft flex h-[unset] w-full flex-nowrap py-4 text-left transition-all motion-safe:hover:-translate-y-0.5 {props.class}">
|
||||
<div class="flex grow flex-row items-center gap-4">
|
||||
<div class="bg-base-content/5 flex size-12 shrink-0 items-center justify-center rounded-2xl">
|
||||
<div class="btn flex h-[unset] w-full flex-nowrap py-4 text-left {props.class}">
|
||||
<div class="flex grow flex-row items-start gap-4">
|
||||
<div class="flex h-14 w-12 shrink-0 items-center justify-center">
|
||||
{@render props.icon?.()}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-lg font-bold">
|
||||
<p class="text-bold text-lg">
|
||||
{@render props.title?.()}
|
||||
</p>
|
||||
<p class="text-sm font-normal opacity-70">
|
||||
<p class="text-sm">
|
||||
{@render props.info?.()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden h-14 w-14 items-center justify-end sm:flex">
|
||||
<Icon
|
||||
size={7}
|
||||
icon={AltArrowRight}
|
||||
class="transition-transform motion-safe:group-hover:translate-x-1" />
|
||||
<Icon size={7} icon={AltArrowRight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
|
||||
const innerClass = $derived(
|
||||
cx(
|
||||
"relative text-base-content grow pointer-events-auto",
|
||||
"rounded-t-box sm:rounded-box sm:rounded-[2rem] ring-1 ring-base-content/5",
|
||||
"relative text-base-content text-base-content grow pointer-events-auto",
|
||||
"rounded-t-box sm:rounded-box",
|
||||
{
|
||||
"bg-alt shadow-2xl max-h-[90vh] flex flex-col max-w-full pb-sai sm:pb-0": !fullscreen,
|
||||
"bg-alt shadow-m max-h-[90vh] flex flex-col max-w-full pb-sai sm:pb-0": !fullscreen,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -44,11 +44,11 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="center fixed inset-0 z-modal">
|
||||
<div class="dialog center fixed inset-0 z-modal">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close dialog"
|
||||
class="absolute inset-0 cursor-pointer bg-[oklch(12%_0.03_285)] opacity-50 backdrop-blur-sm dark:opacity-70"
|
||||
class="absolute inset-0 cursor-pointer bg-black opacity-50 dark:opacity-75"
|
||||
transition:fade={{duration: 200}}
|
||||
onclick={onClose}>
|
||||
</button>
|
||||
|
||||
@@ -8,18 +8,10 @@
|
||||
const {children, ...props}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-3 p-2 text-xs">
|
||||
<div class="flex items-center gap-2 p-2 text-xs uppercase opacity-50">
|
||||
<div class="h-px grow bg-base-content opacity-25"></div>
|
||||
{#if children}
|
||||
<div class="via-base-content/20 h-px grow bg-gradient-to-r from-transparent to-transparent">
|
||||
</div>
|
||||
<p
|
||||
class="font-display bg-base-100 shadow-soft rounded-full px-3 py-1 font-semibold tracking-wide uppercase opacity-80">
|
||||
{@render children?.()}
|
||||
</p>
|
||||
<div class="via-base-content/20 h-px grow bg-gradient-to-r from-transparent to-transparent">
|
||||
</div>
|
||||
{:else}
|
||||
<div class="via-base-content/20 h-px grow bg-gradient-to-r from-transparent to-transparent">
|
||||
</div>
|
||||
<p>{@render children?.()}</p>
|
||||
<div class="h-px grow bg-base-content opacity-25"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class={cx("fixed bottom-20 mb-sai right-4 z-nav hide-on-keyboard md:hidden", className)}>
|
||||
<Button
|
||||
class="btn btn-primary size-14 rounded-full border-none p-0 shadow-[0_8px_24px_-6px_var(--color-primary)] transition-transform motion-safe:hover:scale-110 motion-safe:active:scale-95"
|
||||
class="btn btn-primary border-none shadow-xl hover:opacity-90 transition-all size-[50px] rounded-xl p-0"
|
||||
{onclick}>
|
||||
<div class="flex items-center justify-center">
|
||||
{@render children?.()}
|
||||
|
||||
@@ -6,16 +6,9 @@
|
||||
alt: string
|
||||
size?: number
|
||||
class?: string
|
||||
style?: string
|
||||
}
|
||||
|
||||
const {src, alt, size = 5, style = "", ...props}: Props = $props()
|
||||
|
||||
// Tailwind can't compile interpolated `h-{size}` classes, so size the box
|
||||
// with an inline style (size * 4 == the Tailwind `h-{size}` rem scale).
|
||||
const px = size * 4
|
||||
|
||||
let loaded = $state(false)
|
||||
const {src, alt, size = 5, ...props}: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if src.includes("image/svg") || src.endsWith(".svg")}
|
||||
@@ -24,10 +17,5 @@
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
style="width:{px}px;height:{px}px;min-width:{px}px;min-height:{px}px;{style}"
|
||||
class="aspect-square object-cover motion-safe:transition-opacity motion-safe:duration-300 {loaded
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'} {props.class}"
|
||||
onload={() => (loaded = true)}
|
||||
onerror={() => (loaded = true)} />
|
||||
class="h-{size} w-{size} min-w-{size} min-h-{size} aspect-square object-cover {props.class}" />
|
||||
{/if}
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
|
||||
<div
|
||||
data-component="Page"
|
||||
class="relative grow flex flex-col min-w-0 ml-sai mb-sai mt-sai mr-sai bg-base-200 md:ml-0 md:mb-0 md:rounded-tl-2xl {props.class}">
|
||||
class="relative grow flex flex-col min-w-0 ml-sai mb-sai mt-sai mr-sai bg-base-200 md:ml-0 md:mb-0 {props.class}">
|
||||
{@render props.children?.()}
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
</script>
|
||||
|
||||
<div class="column gap-4 py-12">
|
||||
<h1 class="font-display text-center text-4xl leading-tight font-bold tracking-tight">
|
||||
{@render title?.()}
|
||||
</h1>
|
||||
<h1 class="superheading">{@render title?.()}</h1>
|
||||
<p class="text-center">{@render info?.()}</p>
|
||||
</div>
|
||||
|
||||
@@ -16,46 +16,33 @@
|
||||
const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus"))
|
||||
|
||||
const wrapperClass = $derived(
|
||||
cx("group relative h-14 w-14 p-1", {
|
||||
cx("relative h-14 w-14 p-1", {
|
||||
"tooltip tooltip-right": title,
|
||||
}),
|
||||
)
|
||||
|
||||
const innerClass = $derived(
|
||||
cx(
|
||||
"relative flex h-full w-full cursor-pointer items-center justify-center transition-all duration-200 hover:bg-base-300 motion-safe:hover:scale-105 motion-safe:hover:-rotate-3",
|
||||
"flex h-full w-full cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-base-300",
|
||||
restProps.class,
|
||||
active
|
||||
? "rounded-[42%] bg-base-300 ring-2 ring-primary/60 shadow-[0_0_14px_-3px_var(--color-primary)]"
|
||||
: "rounded-2xl",
|
||||
{"bg-base-300 border border-solid border-base-content/20": active},
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class={wrapperClass} data-tip={title}>
|
||||
<!-- Discord-style accent pill: tall when active, a nub on hover -->
|
||||
<div
|
||||
class={cx(
|
||||
"pointer-events-none absolute top-1/2 left-0 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-all duration-200",
|
||||
active ? "h-8 opacity-100" : "h-2 opacity-0 group-hover:h-4 group-hover:opacity-60",
|
||||
)}>
|
||||
</div>
|
||||
{#if onclick}
|
||||
<Button {onclick} class={innerClass}>
|
||||
{@render children?.()}
|
||||
{#if !active && notification}
|
||||
<div
|
||||
class="bg-secondary ring-base-200 absolute top-1 right-1 h-2.5 w-2.5 rounded-full ring-2 motion-safe:animate-pulse">
|
||||
</div>
|
||||
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
|
||||
{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
<a {href} class={innerClass}>
|
||||
{@render children?.()}
|
||||
{#if !active && notification}
|
||||
<div
|
||||
class="bg-secondary ring-base-200 absolute top-1 right-1 h-2.5 w-2.5 rounded-full ring-2 motion-safe:animate-pulse">
|
||||
</div>
|
||||
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<div
|
||||
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 md:shadow-[6px_0_24px_-12px_rgba(0,0,0,0.35)]",
|
||||
"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",
|
||||
props.class,
|
||||
)}>
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
const {children}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="label flex items-center justify-between px-1 py-2">
|
||||
<div class="flex items-center justify-between px-1 py-2 text-sm font-bold uppercase">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -36,15 +36,11 @@
|
||||
|
||||
const active = $derived($page.url.pathname === href)
|
||||
const wrapperClass = $derived(
|
||||
cx(
|
||||
restProps.class,
|
||||
"group relative flex shrink-0 items-center gap-3 rounded-xl text-left transition-all",
|
||||
{
|
||||
"hover:bg-base-100": true,
|
||||
"bg-primary/15 text-primary font-semibold": active,
|
||||
"tooltip tooltip-right": title,
|
||||
},
|
||||
),
|
||||
cx(restProps.class, "relative flex shrink-0 items-center gap-3 text-left transition-all", {
|
||||
"hover:bg-base-100 hover:text-base-content": true,
|
||||
"text-base-content bg-base-100": active,
|
||||
"tooltip tooltip-right": title,
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -55,19 +51,16 @@
|
||||
data-tip={title}
|
||||
data-sveltekit-replacestate={replaceState}
|
||||
class={wrapperClass}>
|
||||
{#if active}
|
||||
<div class="bg-primary absolute top-1/2 left-0 h-5 w-1 -translate-y-1/2 rounded-r-full"></div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{#if notification}
|
||||
<div class="bg-secondary absolute top-5 right-[1.15rem] h-2 w-2 rounded-full" transition:fade>
|
||||
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button {...restProps} data-tip={title} class={wrapperClass}>
|
||||
{#if notification}
|
||||
<div class="bg-secondary absolute top-5 right-[1.15rem] h-2 w-2 rounded-full" transition:fade>
|
||||
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
<span class="flex min-h-10 items-center">
|
||||
{#if loading}
|
||||
<span class="pr-3" transition:slide|local={{axis: "x"}}>
|
||||
<span class="loading loading-dots text-primary" transition:fade|local={{duration: 100}}
|
||||
></span>
|
||||
<span class="loading loading-spinner" transition:fade|local={{duration: 100}}></span>
|
||||
</span>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
// @ts-nocheck
|
||||
import {cubicOut, backOut} from "svelte/easing"
|
||||
import {cubicOut} from "svelte/easing"
|
||||
import type {FlyParams} from "svelte/transition"
|
||||
import {fly as baseFly} from "svelte/transition"
|
||||
|
||||
export {fade, slide, scale} from "svelte/transition"
|
||||
export {fade, slide} from "svelte/transition"
|
||||
|
||||
// A short, gently-springy fly is the app's default element entrance.
|
||||
export const fly = (node: Element, params?: FlyParams | undefined) =>
|
||||
baseFly(node, {y: 12, duration: 250, easing: backOut, ...params})
|
||||
|
||||
// Staggered variant for list items — pass {i} (the index) to cascade the
|
||||
// entrance. Delay is capped so long/virtualized lists never feel sluggish.
|
||||
export const flyStagger = (node: Element, {i = 0, ...params}: any = {}) =>
|
||||
baseFly(node, {y: 12, duration: 250, easing: backOut, delay: Math.min(i * 40, 240), ...params})
|
||||
baseFly(node, {y: 20, ...params})
|
||||
|
||||
export type TranslateParams = {
|
||||
delay?: number
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import {goToSpace} from "@app/routes"
|
||||
import {PLATFORM_NAME, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
|
||||
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/env"
|
||||
|
||||
const openChat = () => goto("/chat")
|
||||
|
||||
@@ -19,28 +19,16 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="hero relative min-h-screen overflow-auto pb-8">
|
||||
<!-- Soft brand-color blobs for a warm, playful backdrop -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div class="bg-primary/10 absolute top-10 -left-20 size-72 rounded-full blur-3xl"></div>
|
||||
<div class="bg-secondary/10 absolute right-[-4rem] bottom-10 size-72 rounded-full blur-3xl">
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-content relative">
|
||||
<div class="hero min-h-screen overflow-auto pb-8">
|
||||
<div class="hero-content">
|
||||
<div class="column content gap-4">
|
||||
<div class="mb-2 flex flex-col items-center gap-3">
|
||||
<img
|
||||
src={PLATFORM_LOGO}
|
||||
alt={PLATFORM_NAME}
|
||||
class="shadow-soft ring-primary/20 size-20 rounded-3xl object-cover ring-4 motion-safe:animate-float" />
|
||||
<h1 class="font-display text-2xl font-semibold opacity-60">Welcome to</h1>
|
||||
<h1 class="brand text-6xl">{PLATFORM_NAME}</h1>
|
||||
</div>
|
||||
<h1 class="text-center text-5xl">Welcome to</h1>
|
||||
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
|
||||
<div class="col-3">
|
||||
<Link href="/spaces">
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<Icon icon={AddCircle} size={7} class="text-primary" />
|
||||
<Icon icon={AddCircle} size={7} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Add a space</div>
|
||||
@@ -53,7 +41,7 @@
|
||||
<Button onclick={openChat}>
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<Icon icon={ChatRound} size={7} class="text-secondary" />
|
||||
<Icon icon={ChatRound} size={7} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Start a conversation</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user