Compare commits

..

13 Commits

Author SHA1 Message Date
mplorentz ea4e1cde31 Show unread indicator on chat icon in VoiceWidget 2026-04-03 10:51:28 -04:00
mplorentz 4f2e494959 Allow clicking voice widget to go back to call 2026-04-03 10:41:05 -04:00
mplorentz fef449be85 rework video + text chat display controls 2026-04-03 10:36:14 -04:00
mplorentz 945e853e3b Style pin icon more better 2026-04-03 10:01:13 -04:00
mplorentz bad96500d5 Style voice widget icons to be less red 2026-04-03 09:58:00 -04:00
mplorentz 148286dc04 Add video settings to VoiceCallAudioSettingsDialog 2026-04-03 09:23:53 -04:00
mplorentz 3decff3cfc Fix merge artifacts 2026-04-03 09:19:32 -04:00
mplorentz b4b8f85e18 Add settings button to configure audio devices in call 2026-04-03 09:13:06 -04:00
mplorentz 6cc21de400 Change screen sharing icon 2026-04-03 09:11:18 -04:00
mplorentz 39e851b735 Improve pinned video layout 2026-04-03 09:11:18 -04:00
mplorentz 81ff1cafdc Add a button to spotlight a video feed 2026-04-03 09:11:18 -04:00
mplorentz 008dd246ef Add basic screen sharing 2026-04-03 09:11:18 -04:00
mplorentz 50ccfa775f add video to livekit calls 2026-04-03 09:11:18 -04:00
172 changed files with 2553 additions and 4812 deletions
-1
View File
@@ -19,6 +19,5 @@ VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+1 -2
View File
@@ -1,5 +1,4 @@
src/assets
.claude
target
build
.idea
@@ -14,4 +13,4 @@ ios/App/Pods/
android/capacitor-cordova-android-plugins
android/app/src/androidTest
android/app/src/test
node_modules
-3
View File
@@ -28,7 +28,6 @@ node_modules/
.pnpm-store/
build/
.svelte-kit/
.next/
# Rust/Tauri
*target/
@@ -70,9 +69,7 @@ GoogleService-Info.plist
.roo
.idea/
.vscode/
.claude/
# OS generated
.DS_Store
Thumbs.db
package-lock.json
-31
View File
@@ -1,36 +1,5 @@
# Changelog
# 1.7.4
* Fix safe area inset for FAB
# 1.7.3
* Add native share support for space invites
* Stop sending duplicate requests per room
* Add more robust thumbnail url generation
* Make space reordering discoverable with smoother drag animation
* Improve relay member list
* Add room mentions and clickable room/relay refs
* Support native clipboard image paste on mobile
* publish kind 9 quote after room content creation for cross-client interoperability
* Improve feed pagination logic and performance
* Support Aegis URL scheme for NIP-46 login
* Various UI and bug fixes
* Raise message size limit in chat
* Fix realtime updates for room members and admins
* Add video to calls
* Remove follow graph building
* Add start chat FAB
* Add drafts
* Redesign toast notifications
* Remove room/space leave indications
* Hide report badge for non-admin users
* Add polls
* Add search to recent activity page
* Fix notification badge on mobile nav
* Change audio devices in call
# 1.7.2
* Fix race condition in nip 46
+2 -2
View File
@@ -8,8 +8,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 46
versionName "1.7.4"
versionCode 44
versionName "1.7.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
-2
View File
@@ -12,12 +12,10 @@ dependencies {
implementation project(':aparajita-capacitor-secure-storage')
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-android-dark-mode-support')
implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin')
-3
View File
@@ -44,7 +44,4 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
</manifest>
@@ -7,7 +7,6 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import com.getcapacitor.JSObject
@@ -77,7 +76,6 @@ class AndroidPushFallbackPlugin : Plugin() {
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
.setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
workManager.enqueueUniquePeriodicWork(
@@ -43,7 +43,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
private const val TAG = "PushFallback"
private const val CHANNEL_ID = "flotilla_fallback"
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
private const val SOCKET_TIMEOUT_SECONDS = 30L
private const val SOCKET_TIMEOUT_SECONDS = 20L
private const val REJECTED = "__REJECTED__"
private const val KIND_RELAY_AUTH = 22242
private const val KIND_NIP46_RPC = 24133
@@ -72,8 +72,6 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
}
override fun doWork(): Result {
Log.i(TAG, "doWork() started")
if (isAppInForeground()) {
return Result.success()
}
@@ -90,7 +88,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
val activeSince = state.optLong("activeSince", 0L)
val seen = mutableSetOf<String>()
val newEvents = mutableListOf<Pair<String, JSONObject>>()
var latestPair: Pair<String, JSONObject>? = null
for (sub in subscriptions) {
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
@@ -104,19 +102,23 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
for (event in result.events) {
val id = event.optString("id", "")
if (id.isNotEmpty() && seen.add(id)) {
newEvents.add(Pair(sub.relay, event))
val createdAt = event.optLong("created_at", 0L)
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
latestPair = Pair(sub.relay, event)
}
}
}
}
for ((relay, event) in newEvents) {
if (latestPair != null) {
val (relay, event) = latestPair!!
postNotification(relay, event)
}
return Result.success()
} catch (e: Exception) {
Log.e(TAG, "Worker failed", e)
return Result.retry()
return Result.success()
} finally {
pool.closeAll()
client.dispatcher.executorService.shutdown()
@@ -212,8 +214,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
.setContentIntent(pendingIntent)
.build()
val notificationId = id.hashCode().let { if (it == 0) 1 else it }
NotificationManagerCompat.from(context).notify(notificationId, notification)
NotificationManagerCompat.from(context).notify(1, notification)
}
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
-6
View File
@@ -11,9 +11,6 @@ project(':capacitor-community-safe-area').projectDir = new File('../node_modules
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')
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')
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')
@@ -26,9 +23,6 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@
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')
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')
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')
+4 -4
View File
@@ -358,14 +358,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37;
CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.4;
MARKETING_VERSION = 1.7.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -385,14 +385,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37;
CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.4;
MARKETING_VERSION = 1.7.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+1 -3
View File
@@ -24,10 +24,8 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Flotilla uses the camera when you enable it in a voice room.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
<string>Flotilla uses the microphone for voice chat in rooms.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
-2
View File
@@ -14,12 +14,10 @@ def capacitor_pods
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_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
end
+16 -21
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "1.7.4",
"version": "1.7.2",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -22,7 +22,6 @@
"@eslint/js": "^9.39.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "^2.9.6",
"@types/eslint": "^9.6.1",
"autoprefixer": "^10.4.23",
@@ -36,7 +35,7 @@
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.48.0",
"svelte-check": "^4.3.5",
"tailwindcss": "^4.2.2",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^5.4.21"
@@ -48,40 +47,37 @@
"@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0",
"@capacitor/cli": "^8.0.1",
"@capacitor/clipboard": "^8.0.1",
"@capacitor/core": "^8.0.1",
"@capacitor/filesystem": "^8.1.0",
"@capacitor/ios": "^8.0.1",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/preferences": "^8.0.0",
"@capacitor/push-notifications": "^8.0.0",
"@capacitor/share": "^8.0.1",
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
"@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2",
"@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.3",
"@pomade/core": "^0.2.2",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.8.13",
"@welshman/content": "^0.8.13",
"@welshman/editor": "^0.8.13",
"@welshman/feeds": "^0.8.13",
"@welshman/lib": "^0.8.13",
"@welshman/net": "^0.8.13",
"@welshman/router": "^0.8.13",
"@welshman/signer": "^0.8.13",
"@welshman/store": "^0.8.13",
"@welshman/util": "^0.8.13",
"@welshman/app": "^0.8.12",
"@welshman/content": "^0.8.12",
"@welshman/editor": "^0.8.12",
"@welshman/feeds": "^0.8.12",
"@welshman/lib": "^0.8.12",
"@welshman/net": "^0.8.12",
"@welshman/router": "^0.8.12",
"@welshman/signer": "^0.8.12",
"@welshman/store": "^0.8.12",
"@welshman/util": "^0.8.12",
"compressorjs-next": "^1.1.2",
"daisyui": "^5.5.19",
"daisyui": "^4.12.24",
"date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1",
@@ -91,7 +87,7 @@
"livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
@@ -108,6 +104,5 @@
"overrides": {
"sharp": "0.35.0-rc.0"
}
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
}
}
+483 -529
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,5 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
tailwindcss: {},
autoprefixer: {},
},
}
+314 -267
View File
@@ -1,6 +1,46 @@
@import "tailwindcss";
@import "@welshman/editor/index.css";
@config "../tailwind.config.js";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Fonts */
@font-face {
font-family: "Satoshis";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Satoshi Symbol.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Lato-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: bold;
font-weight: 600;
src:
local(""),
url("/fonts/Lato-Bold.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: italic;
font-weight: 400;
src:
local(""),
url("/fonts/Italic.ttf") format("truetype");
}
/* root */
@@ -10,246 +50,101 @@
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
--video-call-panel-bg: #181e24;
}
@utility pt-sai {
padding-top: var(--sait);
[data-theme] {
@apply bg-base-300;
--base-100: oklch(var(--b1));
--base-200: oklch(var(--b2));
--base-300: oklch(var(--b3));
--base-content: oklch(var(--bc));
--primary: oklch(var(--p));
--primary-content: oklch(var(--pc));
--secondary: oklch(var(--s));
--secondary-content: oklch(var(--sc));
--neutral: oklch(var(--n));
--neutral-content: oklch(var(--nc));
}
@utility pr-sai {
padding-right: var(--sair);
.mobile [data-tip]::before {
display: none !important;
}
@utility pb-sai {
padding-bottom: var(--saib);
}
/* safe area insets */
@utility pl-sai {
padding-left: var(--sail);
}
@utility px-sai {
@apply pl-sai pr-sai;
}
@utility py-sai {
@apply pt-sai pb-sai;
}
@utility p-sai {
@apply py-sai px-sai;
}
@utility mt-sai {
margin-top: var(--sait);
}
@utility mr-sai {
margin-right: var(--sair);
}
@utility mb-sai {
margin-bottom: var(--saib);
}
@utility ml-sai {
margin-left: var(--sail);
}
@utility mx-sai {
@apply ml-sai mr-sai;
}
@utility my-sai {
@apply mt-sai mb-sai;
}
@utility m-sai {
@apply my-sai mx-sai;
}
@utility top-sai {
top: var(--sait);
}
@utility right-sai {
right: var(--sair);
}
@utility bottom-sai {
bottom: var(--saib);
}
@utility left-sai {
left: var(--sail);
}
@utility card2 {
@apply rounded-box text-base-content p-4 sm:p-6;
}
@utility column {
@apply flex flex-col;
}
@utility center {
@apply flex items-center justify-center;
}
@utility row-2 {
@apply flex items-center gap-2;
}
@utility row-3 {
@apply flex items-center gap-3;
}
@utility row-4 {
@apply flex items-center gap-4;
}
@utility col-2 {
@apply flex flex-col gap-2;
}
@utility col-3 {
@apply flex flex-col gap-3;
}
@utility col-4 {
@apply flex flex-col gap-4;
}
@utility col-8 {
@apply flex flex-col gap-8;
}
@utility ellipsize {
@apply overflow-hidden text-ellipsis;
}
@utility content-padding-x {
@apply px-4 sm:px-8 md:px-12;
}
@utility content-padding-t {
@apply pt-4 sm:pt-8 md:pt-12;
}
@utility content-padding-b {
@apply pb-4 sm:pb-8 md:pb-12;
}
@utility content-padding-y {
@apply pt-4 pb-4 sm:pt-8 sm:pb-8 md:pt-12 md:pb-12;
}
@utility content-sizing {
@apply m-auto w-full max-w-3xl;
}
@utility content {
@apply m-auto w-full max-w-3xl px-4 pt-4 pb-4 sm:px-8 sm:pt-8 sm:pb-8 md:px-12 md:pt-12 md:pb-12;
}
@utility heading {
@apply text-center text-2xl;
}
@utility subheading {
@apply text-center text-xl;
}
@utility superheading {
@apply text-center text-4xl;
}
@utility link {
@apply text-primary cursor-pointer underline;
}
/* content visibility */
@utility cv {
content-visibility: auto;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer utilities {
/* Fonts */
@font-face {
font-family: "Satoshis";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Satoshi Symbol.ttf") format("truetype");
@layer components {
.pt-sai {
padding-top: var(--sait);
}
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Lato-Regular.ttf") format("truetype");
.pr-sai {
padding-right: var(--sair);
}
@font-face {
font-family: "Lato";
font-style: bold;
font-weight: 600;
src:
local(""),
url("/fonts/Lato-Bold.ttf") format("truetype");
.pb-sai {
padding-bottom: var(--saib);
}
@font-face {
font-family: "Lato";
font-style: italic;
font-weight: 400;
src:
local(""),
url("/fonts/Italic.ttf") format("truetype");
.pl-sai {
padding-left: var(--sail);
}
/* root */
:root {
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));
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
.px-sai {
@apply pl-sai pr-sai;
}
[data-theme] {
@apply bg-base-300;
.py-sai {
@apply pt-sai pb-sai;
}
.mobile [data-tip]::before {
display: none !important;
.p-sai {
@apply py-sai px-sai;
}
/* safe area insets */
.mt-sai {
margin-top: var(--sait);
}
.mr-sai {
margin-right: var(--sair);
}
.mb-sai {
margin-bottom: var(--saib);
}
.ml-sai {
margin-left: var(--sail);
}
.mx-sai {
@apply ml-sai mr-sai;
}
.my-sai {
@apply mt-sai mb-sai;
}
.m-sai {
@apply my-sai mx-sai;
}
.top-sai {
top: var(--sait);
}
.right-sai {
right: var(--sair);
}
.bottom-sai {
bottom: var(--saib);
}
.left-sai {
left: var(--sail);
}
}
/* utilities */
@@ -271,18 +166,110 @@
@apply bg-base-300 text-base-content transition-colors;
}
.card2 {
@apply rounded-box p-4 text-base-content sm:p-6;
}
.card2.card2-sm {
@apply text-base-content p-2 sm:p-4;
@apply p-2 text-base-content sm:p-4;
}
.column {
@apply flex flex-col;
}
.center {
@apply flex items-center justify-center;
}
.row-2 {
@apply flex items-center gap-2;
}
.row-3 {
@apply flex items-center gap-3;
}
.row-4 {
@apply flex items-center gap-4;
}
.col-2 {
@apply flex flex-col gap-2;
}
.col-3 {
@apply flex flex-col gap-3;
}
.col-4 {
@apply flex flex-col gap-4;
}
.col-8 {
@apply flex flex-col gap-8;
}
.badge {
@apply justify-start overflow-hidden text-ellipsis whitespace-nowrap;
}
.ellipsize {
@apply overflow-hidden text-ellipsis;
}
[data-tip]::before {
@apply overflow-hidden text-ellipsis;
@apply ellipsize;
}
.content-padding-x {
@apply px-4 sm:px-8 md:px-12;
}
.content-padding-t {
@apply pt-4 sm:pt-8 md:pt-12;
}
.content-padding-b {
@apply pb-4 sm:pb-8 md:pb-12;
}
.content-padding-y {
@apply content-padding-t content-padding-b;
}
.content-sizing {
@apply m-auto w-full max-w-3xl;
}
.content {
@apply content-sizing content-padding-x content-padding-y;
}
.heading {
@apply text-center text-2xl;
}
.subheading {
@apply text-center text-xl;
}
.superheading {
@apply text-center text-4xl;
}
.link {
@apply cursor-pointer text-primary underline;
}
.input input::placeholder {
opacity: 0.5;
}
.shadow-top-xl {
@apply shadow-[0_20px_25px_-5px_rgb(0,0,0,0.1)_0_8px_10px_-6px_rgb(0,0,0,0.1)];
}
/* tiptap */
.input-editor,
@@ -292,21 +279,21 @@
}
.tiptap {
--tiptap-object-bg: var(--color-neutral);
--tiptap-object-fg: var(--color-neutral-content);
--tiptap-active-bg: var(--color-primary);
--tiptap-active-fg: var(--color-primary-content);
--tiptap-object-bg: var(--neutral);
--tiptap-object-fg: var(--neutral-content);
--tiptap-active-bg: var(--primary);
--tiptap-active-fg: var(--primary-content);
}
.tiptap-suggestions {
--tiptap-object-bg: var(--color-base-100);
--tiptap-object-fg: var(--color-base-content);
--tiptap-active-bg: var(--color-base-300);
--tiptap-active-fg: var(--color-base-content);
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--base-300);
--tiptap-active-fg: var(--base-content);
}
.tiptap-suggestions__item {
@apply border-base-100 border-l-2 border-solid;
@apply border-l-2 border-solid border-base-100;
}
.tiptap-suggestions__selected {
@@ -326,13 +313,13 @@
}
.note-editor .tiptap {
--tiptap-object-bg: var(--color-base-200);
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
}
.input-editor .tiptap {
--tiptap-object-bg: var(--color-base-200);
@apply input h-auto p-[.65rem];
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto p-[.65rem];
}
/* link-content, based on tiptap */
@@ -344,8 +331,8 @@
white-space: nowrap;
border-radius: 3px;
padding: 0 0.25rem;
background-color: var(--color-base-100);
color: var(--color-base-content);
background-color: var(--base-100);
color: var(--base-content);
}
/* content rendered by welshman/content */
@@ -361,31 +348,23 @@
/* date input */
.picker {
--date-picker-foreground: var(--color-base-content);
--date-picker-background: var(--color-base-300);
--date-picker-highlight-border: var(--color-primary);
--date-picker-selected-color: var(--color-primary-content);
--date-picker-selected-background: var(--color-primary);
--date-picker-foreground: var(--base-content);
--date-picker-background: var(--base-300);
--date-picker-highlight-border: var(--primary);
--date-picker-selected-color: var(--primary-content);
--date-picker-selected-background: var(--primary);
}
.date-time-field {
@apply input rounded-lg px-0;
@apply input input-bordered rounded-lg px-0;
}
.date-time-field input {
@apply h-full! w-full! rounded-lg! border-none! bg-inherit! px-4! text-inherit!;
@apply !h-full !w-full !rounded-lg !border-none !bg-inherit !px-4 !text-inherit;
}
/* tippy popover */
.tippy-target {
@apply z-tooltip pointer-events-none fixed inset-0;
}
.tippy-target > * {
pointer-events: auto;
}
.tippy-box {
@apply rounded-box shadow-xl;
}
@@ -393,15 +372,15 @@
/* emoji picker */
emoji-picker {
--background: var(--color-base-100);
--border-color: var(--color-base-100);
--background: var(--base-100);
--border-color: var(--base-100);
--border-radius: var(--rounded-box);
--button-active-background: var(--color-base-content);
--button-hover-background: var(--color-base-content);
--indicator-color: var(--color-base-content);
--input-border-color: var(--color-base-100);
--input-font-color: var(--color-base-content);
--outline-color: var(--color-base-100);
--button-active-background: var(--base-content);
--button-hover-background: var(--base-content);
--indicator-color: var(--base-content);
--input-border-color: var(--base-100);
--input-font-color: var(--base-content);
--outline-color: var(--base-100);
}
/* progress */
@@ -412,12 +391,57 @@ progress[value]::-webkit-progress-value {
/* content width for fixed elements */
.left-content {
@apply md:left-[calc(18.5rem+var(--sail))];
.cw {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cw-video-call-content {
@apply w-full md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
}
/* Voice: desktop split — plain CSS so / in calc is not parsed as Tailwind slash syntax */
.cw-split-video {
width: 100%;
}
.cw-split-chat {
width: 100%;
}
@media (min-width: 768px) {
.cw-split-video {
left: 18.5rem;
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
.cw-split-chat {
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
}
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
}
.cb {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
}
.ct {
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
}
/* Keyboard open state adjustments */
body.keyboard-open .cb {
@apply bottom-sai;
}
body.keyboard-open .hide-on-keyboard {
display: none;
}
@@ -425,13 +449,36 @@ body.keyboard-open .hide-on-keyboard {
/* chat view */
.chat__compose {
@apply z-compose relative mb-14 shrink-0 md:mb-0;
@apply cb cw fixed z-compose;
}
.chat__compose .chat__compose-inner {
.chat__compose-zone {
@apply cb cw fixed z-compose;
}
.chat__compose-zone .chat__compose-inner {
@apply min-w-0;
}
.chat__scroll-down {
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
.chat__compose-zone.cw-video-call-content {
@apply md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
}
@media (min-width: 768px) {
.chat__compose-zone.cw-split-chat {
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
}
.chat__scroll-down {
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
}
/* content visibility */
.cv {
content-visibility: auto;
}
-57
View File
@@ -1,57 +0,0 @@
import {Room as LiveKitRoom} from "livekit-client"
import {derived, writable} from "svelte/store"
import {type Room} from "@app/core/state"
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
muted: boolean
cameraOn: boolean
screenShareOn: boolean
}
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export enum VoiceState {
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? {pubkey: pk, identity} : {identity}
}
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
export const isLocalSpeaking = derived(
[currentVoiceSession, speakingParticipants],
([$session, $speaking]) => {
if (!$session?.room) return false
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
return $speaking.some(sp => participantKey(sp) === participantKey(local))
},
)
-99
View File
@@ -1,99 +0,0 @@
import {Track} from "livekit-client"
import {MediaQuery} from "svelte/reactivity"
import {derived, get, writable} from "svelte/store"
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
import {pushToast} from "@app/util/toast"
export enum VideoCallLayout {
Chat = "chat",
Video = "video",
Split = "split",
}
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export const videoCallViewportSync = {
previousLayout: undefined as ViewportSize | undefined,
}
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
export const triggerVideoFeedCount = () => {
currentVoiceSession.update(s => (s ? {...s} : s))
}
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
if ($state !== VoiceState.Connected || !$session) return 0
return countLiveVisualFeeds($session)
})
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
try {
await session.room.localParticipant.setCameraEnabled(cameraOn)
currentVoiceSession.set({...session, cameraOn})
} catch {
pushToast({
theme: "error",
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
try {
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
currentVoiceSession.set({...session, screenShareOn})
} catch {
pushToast({
theme: "error",
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
})
}
}
@@ -38,7 +38,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
@@ -7,13 +7,12 @@
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const {url, h}: Props = $props()
</script>
<CalendarEventForm {url} {h} {shareToChat}>
<CalendarEventForm {url} {h}>
{#snippet header()}
<ModalHeader>
<ModalTitle>Create an Event</ModalTitle>
+36 -80
View File
@@ -3,7 +3,7 @@
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@@ -20,34 +20,24 @@
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = {
d: string
title: string
content: string | object
location: string
start?: number
end?: number
}
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
h?: string
shareToChat?: boolean
header: Snippet
initialValues?: Values
initialValues?: {
d: string
title: string
content: string
location: string
start: number
end: number
}
}
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const {url, h, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -58,7 +48,7 @@
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading || loading) return
if ($uploading) return
if (!title) {
return pushToast({
@@ -84,68 +74,38 @@
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [
["d", d],
["d", initialValues?.d || randomId()],
["title", title],
["location", location],
["location", location || ""],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
]
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
const calendarThunk = publishThunk({event, relays: [url]})
const error = await waitForThunkError(calendarThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: calendarThunk.event, protect})
}
pushToast({message: "Your event has been saved!"})
} finally {
loading = false
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
history.back()
}
let loading = $state(false)
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, uploading, content})
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let location = $state(initialValues?.location ?? "")
let title = $state(initialValues?.title || "")
let location = $state(initialValues?.location || "")
let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(initialValues?.end)
let endDirty = $state(Boolean(initialValues?.end))
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, uploading, onChange, content})
$effect(() => {
draftKey.set({d, title, location, start, end, content})
})
let endDirty = Boolean(initialValues?.end)
$effect(() => {
if (!endDirty && start) {
@@ -176,14 +136,10 @@
{#snippet input()}
<div
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor grow overflow-hidden">
<div class="input-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="center btn tooltip"
onclick={selectFiles}
disabled={loading}>
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
@@ -222,12 +178,12 @@
</Field>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner loading={$uploading || loading}>Save Event</Spinner>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
<Spinner loading={$uploading}>Save Event</Spinner>
</Button>
</ModalFooter>
</Modal>
@@ -19,7 +19,7 @@
const end = $derived(parseInt(meta.end))
</script>
<div class="flex grow flex-wrap justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p>
{#if !isNaN(start) && !isNaN(end)}
{@const startDateDisplay = formatTimestampAsDate(start)}
+1 -1
View File
@@ -23,7 +23,7 @@
{#if meta.location}
<span class="flex items-start gap-1">
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
<span class="wrap-break-word">{meta.location}</span>
<span class="break-words">{meta.location}</span>
</span>
{/if}
</div>
+20 -7
View File
@@ -55,7 +55,6 @@
import ThunkToast from "@app/components/ThunkToast.svelte"
import {userSettingsValues, deriveChat} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {DraftKey} from "@app/util/drafts"
import {makeDelete, prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
@@ -67,7 +66,6 @@
const {pubkeys, info}: Props = $props()
const chat = deriveChat(pubkeys)
const draftKey = new DraftKey<{content?: string | object}>(`dm:${$chat?.id}`)
const others = remove($pubkey!, pubkeys)
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
@@ -198,6 +196,8 @@
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
const elements = $derived.by(() => {
const elements = []
@@ -233,6 +233,20 @@
for (const pubkey of others) {
loadMessagingRelayList(pubkey)
}
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
})
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
return () => {
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
})
setTimeout(() => {
@@ -279,7 +293,8 @@
</div>
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 py-4">
<PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingRelayLists.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
@@ -320,10 +335,9 @@
</Spinner>
{@render info?.()}
</p>
<div class="h-screen"></div>
</PageContent>
<div class="chat__compose bg-base-200">
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
@@ -338,8 +352,7 @@
{onSubmit}
{onEscape}
{onEditPrevious}
initialValues={eventToEdit}
draftKey={eventToEdit ? undefined : draftKey}
content={eventToEdit?.content}
disabled={Boolean(missingRelayLists.length)} />
{/key}
</div>
+5 -33
View File
@@ -10,40 +10,23 @@
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {type DraftKey} from "@app/util/drafts"
type Values = {
content?: string | object
}
type Props = {
content?: string
disabled?: boolean
draftKey?: DraftKey<Values>
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
}
let {
initialValues,
disabled = false,
draftKey,
onEscape,
onEditPrevious,
onSubmit,
}: Props = $props()
if (!initialValues) {
initialValues = draftKey?.get()
}
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
const autofocus = !isMobile && !disabled
const uploading = writable(false)
const editorClass = $derived(
cx("chat-editor grow overflow-hidden", {
cx("chat-editor flex-grow overflow-hidden", {
"pointer-events-none opacity-50": disabled,
}),
)
@@ -76,29 +59,18 @@
onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run()
}
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
content,
autofocus,
submit,
uploading,
onChange,
aggressive: true,
encryptFiles: true,
})
$effect(() => {
draftKey?.set({content})
})
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
@@ -123,7 +95,7 @@
{/if}
</Button>
<div class={editorClass} aria-disabled={disabled}>
<EditorContent {autofocus} {editor} />
<EditorContent {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
+1 -1
View File
@@ -35,7 +35,7 @@
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
<div
class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}"
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
class:bg-base-100={active}>
<div class="flex flex-col justify-start gap-1">
<div class="flex items-center justify-between gap-2">
+9
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {assoc} from "@welshman/lib"
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
import Check from "@assets/icons/check.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
@@ -7,9 +8,13 @@
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {notificationSettings} from "@app/core/state"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
const markAsRead = () => {
setChecked("/chat/*")
history.back()
@@ -23,6 +28,10 @@
<Modal>
<ModalBody>
<div class="flex flex-col gap-2">
<Button class="btn btn-primary" onclick={startChat}>
<Icon size={5} icon={ChatSquare} />
Start chat
</Button>
<Button class="btn btn-neutral" onclick={markAsRead}>
<Icon size={5} icon={Check} />
Mark all read
+1 -1
View File
@@ -42,7 +42,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
+2 -3
View File
@@ -7,13 +7,12 @@
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const {url, h}: Props = $props()
</script>
<ClassifiedForm {url} {h} {shareToChat}>
<ClassifiedForm {url} {h}>
{#snippet header()}
<ModalHeader>
<ModalTitle>Create a Classified Listing</ModalTitle>
+26 -58
View File
@@ -2,7 +2,7 @@
import type {Snippet} from "svelte"
import {removeUndefined, randomId, uniq} from "@welshman/lib"
import {makeEvent, CLASSIFIED} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import {normalizeTopic} from "@lib/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@@ -20,35 +20,25 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, publishRoomQuote, uploadFile} from "@app/core/commands"
type Values = {
d: string
title: string
content: string | object
price: number
currency: string
images: (string | File)[]
status: string
topics: string[]
}
import {canEnforceNip70, uploadFile} from "@app/core/commands"
type Props = {
url: string
h?: string
shareToChat?: boolean
header: Snippet
initialValues?: Values
initialValues?: {
d?: string
title?: string
content?: string
price?: number
currency?: string
images?: string[]
status?: string
topics?: string[]
}
}
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const {url, h, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -76,7 +66,7 @@
}
const tags = [
["d", d],
["d", initialValues?.d || randomId()],
["title", title],
["summary", content],
["price", String(price), currency],
@@ -88,9 +78,7 @@
tags.push(["t", topic])
}
const protect = await shouldProtect
if (protect) {
if (await shouldProtect) {
tags.push(PROTECTED)
}
@@ -117,47 +105,27 @@
}
}
const classifiedThunk = publishThunk({
publishThunk({
relays: [url],
event: makeEvent(CLASSIFIED, {content, tags}),
})
const error = await waitForThunkError(classifiedThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: classifiedThunk.event, protect})
}
} finally {
loading = false
}
}
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, content})
let loading = $state(false)
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let status = $state(initialValues?.status ?? "active")
let price = $state(initialValues?.price ?? 0)
let currency = $state(initialValues?.currency ?? "SAT")
let images = $state(initialValues?.images ?? [])
let topics = $state(uniq(removeUndefined(initialValues?.topics?.map(normalizeTopic) ?? [])))
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, onChange, content})
$effect(() => {
draftKey.set({d, title, status, price, currency, images, topics, content})
})
let title = $state(initialValues?.title || "")
let status = $state(initialValues?.status || "active")
let price = $state(Number(initialValues?.price || 0))
let currency = $state(initialValues?.currency || "SAT")
let images = $state<(string | File)[]>(initialValues?.images || [])
let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic))))
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -185,7 +153,7 @@
<p>Description*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
+1 -1
View File
@@ -28,7 +28,7 @@
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
+4 -14
View File
@@ -4,7 +4,6 @@
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/util/modal"
@@ -12,7 +11,6 @@
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
import PollCreate from "@app/components/PollCreate.svelte"
type Props = {
url: string
@@ -22,15 +20,13 @@
const {url, h, onClick}: Props = $props()
const createGoal = () => pushModal(GoalCreate, {url, h, shareToChat: true})
const createGoal = () => pushModal(GoalCreate, {url, h})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h, shareToChat: true})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h})
const createThread = () => pushModal(ThreadCreate, {url, h, shareToChat: true})
const createThread = () => pushModal(ThreadCreate, {url, h})
const createClassified = () => pushModal(ClassifiedCreate, {url, h, shareToChat: true})
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
let ul: Element
@@ -64,10 +60,4 @@
Create Thread
</Button>
</li>
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />
Ask a Question
</Button>
</li>
</ul>
+1 -1
View File
@@ -150,7 +150,7 @@
</div>
{:else}
<div
class="overflow-hidden text-ellipsis wrap-break-word"
class="overflow-hidden text-ellipsis break-words"
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
{#each shortContent as parsed, i}
{#if isNewline(parsed) && !isBlock(i - 1)}
+41 -69
View File
@@ -1,44 +1,27 @@
<script lang="ts">
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {Capacitor} from "@capacitor/core"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/util/modal"
import {
dufflepud,
IMAGE_CONTENT_TYPES,
PLATFORM_URL,
VIDEO_CONTENT_TYPES,
THUMBNAIL_URL,
isRoomId,
} from "@app/core/state"
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
let hideImage = $state(false)
const url = value.url.toString()
const isRoomOrRelay = isRoomId(url) || isRelayUrl(url)
const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const fileType = getTagValue("file-type", event.tags) || ""
const getVideoPoster = (videoUrl: string): string | undefined => {
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
return `${THUMBNAIL_URL}/thumbnail?url=${encodeURIComponent(videoUrl)}`
}
return undefined
}
const loadPreview = async () => {
const json = await postJson(dufflepud("link/preview"), {url})
@@ -56,52 +39,41 @@
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
{#if isRoomOrRelay}
<div>
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
<span class="loading loading-spinner"></span>
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
Unable to load a preview for {url}
</p>
{/await}
{/if}
</div>
{:else}
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
<video
controls
src={url}
poster={getVideoPoster(url)}
preload="metadata"
class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
<span class="loading loading-spinner"></span>
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
Unable to load a preview for {url}
</p>
{/await}
{/if}
</div>
</Link>
{/if}
</Link>
+15 -5
View File
@@ -1,18 +1,25 @@
<script lang="ts">
import {displayUrl} from "@welshman/lib"
import {getTagValue} from "@welshman/util"
import {call, displayUrl} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import {pushModal} from "@app/util/modal"
import {IMAGE_CONTENT_TYPES} from "@app/core/state"
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
@@ -27,5 +34,8 @@
{displayUrl(url)}
</a>
{:else}
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
<Link {external} {href} class="link-content whitespace-nowrap">
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</Link>
{/if}
-59
View File
@@ -1,59 +0,0 @@
<script lang="ts">
import {call, displayUrl} from "@welshman/lib"
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import {PLATFORM_URL, displayRoom, isRoomId, splitRoomId} from "@app/core/state"
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
const {
url,
class: className = "",
}: {
url: string
class?: string
} = $props()
const roomReference = call(() => {
if (!isRoomId(url)) {
return undefined
}
const [roomUrl, h] = splitRoomId(url)
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
return undefined
}
return {url: normalizeRelayUrl(roomUrl), h}
})
const relayReference = call(() => {
if (roomReference || !isRelayUrl(url)) {
return undefined
}
return normalizeRelayUrl(url)
})
const [href, external] = call(() => {
if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false]
if (relayReference) return [makeSpacePath(relayReference), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
</script>
<Link {external} {href} class={className}>
{#if roomReference}
~<span class="text-primary">{displayRelayUrl(roomReference.url)}</span> /
{displayRoom(roomReference.url, roomReference.h)}
{:else if relayReference}
<span class="text-primary">{displayRelayUrl(relayReference)}</span>
{:else}
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
{/if}
</Link>
+1 -1
View File
@@ -101,7 +101,7 @@
</p>
</div>
{:else}
<div class="overflow-hidden text-ellipsis wrap-break-word">
<div class="overflow-hidden text-ellipsis break-words">
{#each shortContent as parsed, i}
{#if isNewline(parsed)}
<ContentNewline value={parsed.value} />
+2 -2
View File
@@ -45,11 +45,11 @@
{#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%);">
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
<NoteContentMinimal trimParent {url} event={$quote} />
</div>
{:else}
<NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContentMinimal {url} event={$quote} />
</NoteCard>
{/if}
+2 -3
View File
@@ -42,7 +42,7 @@
let popover: Instance | undefined = $state()
</script>
<div class="join items-center rounded-full">
<Button class="join rounded-full">
{#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
<Icon icon={Bolt} size={4} />
@@ -52,7 +52,6 @@
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
<Tippy
class="flex"
bind:popover
component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}}
@@ -61,4 +60,4 @@
<Icon icon={MenuDots} size={4} />
</Button>
</Tippy>
</div>
</Button>
+2 -2
View File
@@ -101,7 +101,7 @@
{/if}
<div class="relative">
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
<p class="absolute right-2 top-2 flex grow items-center justify-between">
<p class="absolute right-2 top-2 flex flex-grow items-center justify-between">
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
<Icon icon={Copy} /> Copy
</Button>
@@ -109,6 +109,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button>
<Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
</ModalFooter>
</Modal>
+8 -28
View File
@@ -10,19 +10,13 @@
import {publishComment, canEnforceNip70} from "@app/core/commands"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast"
type Values = {
content?: string | object
}
const {url, event, onClose, onSubmit} = $props()
const draftKey = new DraftKey<Values>(`reply:${event.id}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const autofocus = !isMobile
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
@@ -44,23 +38,13 @@
})
}
draftKey.clear()
onSubmit(publishComment({event, content, tags, relays: [url]}))
}
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
let form: HTMLElement
let spacer: HTMLElement
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, uploading, content, onChange})
$effect(() => {
draftKey.set({content})
})
onMount(() => {
setTimeout(() => {
@@ -68,7 +52,7 @@
})
const observer = new ResizeObserver(() => {
spacer!.style.minHeight = `${form!.offsetHeight + 60}px`
spacer!.style.minHeight = `${form!.offsetHeight}px`
})
observer.observe(form!)
@@ -80,15 +64,11 @@
</script>
<div bind:this={spacer}></div>
<form
in:fly
bind:this={form}
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">
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
<div class="card2 mx-2 my-2 bg-alt shadow-md">
<div class="relative">
<div class="note-editor grow overflow-hidden">
<EditorContent {autofocus} {editor} />
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
+1 -1
View File
@@ -30,7 +30,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
+34 -90
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {writable} from "svelte/store"
import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
@@ -10,7 +10,6 @@
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -21,29 +20,14 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = {
title: string
content: string | object
amount: number
}
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
h?: string
initialValues?: Values
shareToChat?: boolean
}
let {url, h, initialValues, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -54,9 +38,9 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) return
if ($uploading) return
if (!title) {
if (!content) {
return pushToast({
theme: "error",
message: "Please provide a title for your funding goal.",
@@ -64,9 +48,9 @@
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const summary = ed.getText({blockSeparator: "\n"}).trim()
if (!content.trim()) {
if (!summary.trim()) {
return pushToast({
theme: "error",
message: "Please provide details about your funding goal.",
@@ -75,68 +59,31 @@
const tags = [
...ed.storage.nostr.getEditorTags(),
["summary", content],
["summary", summary],
["amount", String(amount)],
["relays", url],
]
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const goalThunk = publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content: title, tags}),
})
const error = await waitForThunkError(goalThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: goalThunk.event, protect})
}
} finally {
loading = false
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content, tags}),
})
history.back()
}
let loading = $state(false)
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title = $state(initialValues?.title ?? "")
let amount = $state(initialValues?.amount ?? 1000)
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "What's on your mind?",
content,
})
$effect(() => {
draftKey.update({title, content, amount})
})
let content = $state("")
let amount = $state(1000)
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -155,7 +102,7 @@
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
bind:value={content}
class="grow"
type="text"
placeholder="What do funds go towards?" />
@@ -168,7 +115,7 @@
<p>Details*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
@@ -176,8 +123,7 @@
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
@@ -191,17 +137,17 @@
Goal Amount (sats)*
{/snippet}
{#snippet input()}
<div class="flex grow justify-end">
<label class="input input-bordered flex w-auto items-center gap-2">
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-28 grow" />
<p class="shrink-0 opacity-50">sats</p>
<input bind:value={amount} type="number" class="w-28" />
<p class="opacity-50">sats</p>
</label>
</div>
{/snippet}
</FieldInline>
<input
class="range range-primary -mt-2 w-full"
class="range range-primary -mt-2"
type="range"
min="1000"
max="100000"
@@ -211,12 +157,10 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Goal</Spinner>
</Button>
<Button type="submit" class="btn btn-primary">Create Goal</Button>
</ModalFooter>
</Modal>
+1 -1
View File
@@ -23,7 +23,7 @@
<ModalTitle>Unable to Zap</ModalTitle>
</ModalHeader>
<p>
Zapping <ProfileLink {pubkey} class="text-primary!" /> isn't possible right now because
Zapping <ProfileLink {pubkey} class="!text-primary" /> isn't possible right now because
{#if $zapper}
their zap receiver isn't correctly set up.
{:else}
@@ -97,10 +97,10 @@
tabindex="-1"
onmousedown={stopPropagation(onClear)}
ontouchstart={stopPropagation(onClear)}>
<Icon icon={CloseCircle} class="scale-150 bg-base-300!" />
<Icon icon={CloseCircle} class="scale-150 !bg-base-300" />
</span>
{:else}
<Icon icon={AddCircle} class="scale-150 bg-base-300!" />
<Icon icon={AddCircle} class="scale-150 !bg-base-300" />
{/if}
</div>
{#if !url}
-10
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import {onMount, onDestroy} from "svelte"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker} from "@welshman/signer"
@@ -104,16 +103,10 @@
mode = "connect"
}
const openSigner = () => {
controller.launchSigner()
}
const selectBunker = () => {
mode = "bunker"
}
const isIos = Capacitor.getPlatform() === "ios"
let mode: string = $state("bunker")
$effect(() => {
@@ -145,9 +138,6 @@
<BunkerUrl {controller} />
<Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect}
>Log in with a QR code instead</Button>
{#if isIos}
<Button class="btn btn-neutral" onclick={openSigner}>Open in Signer</Button>
{/if}
{/if}
</ModalBody>
<ModalFooter>
+3 -11
View File
@@ -19,7 +19,6 @@
import LogInOTP from "@app/components/LogInOTP.svelte"
import LogInSelect from "@app/components/LogInSelect.svelte"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushModal, clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
@@ -45,7 +44,7 @@
return pushToast({
theme: "error",
message: getPomadeLoginFailureMessage(messages),
message: "Sorry, we were unable to log you in.",
})
}
@@ -65,17 +64,10 @@
pushToast({
theme: "error",
message: getPomadeLoginFailureMessage(res.messages),
message: "Sorry, we were unable to log you in.",
})
}
}
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
@@ -98,7 +90,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input type="email" bind:value={email} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
+2 -12
View File
@@ -15,7 +15,6 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import LogInOTPConfirm from "@app/components/LogInOTPConfirm.svelte"
import {POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
@@ -36,20 +35,11 @@
if (ok) {
pushModal(LogInOTPConfirm, {email, peersByPrefix})
} else {
console.error("Pomade challenge request failed during OTP login")
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
message: "Sorry, we were unable to request a login code.",
})
}
} catch (error) {
console.error(error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
@@ -71,7 +61,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input type="email" bind:value={email} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
+5 -13
View File
@@ -15,11 +15,10 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
import LogInSelect from "@app/components/LogInSelect.svelte"
import {pushModal, clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushToast} from "@app/util/toast"
import {setChecked} from "@app/util/notifications"
import {pushModal, clearModals} from "@app/util/modal"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
type Props = {
email: string
@@ -45,7 +44,7 @@
return pushToast({
theme: "error",
message: getPomadeLoginFailureMessage(messages),
message: "Sorry, we were unable to log you in.",
})
}
@@ -65,17 +64,10 @@
pushToast({
theme: "error",
message: getPomadeLoginFailureMessage(res.messages),
message: "Sorry, we were unable to log you in.",
})
}
}
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
+1 -9
View File
@@ -14,7 +14,6 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {setChecked} from "@app/util/notifications"
import {clearModals} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
@@ -47,16 +46,9 @@
pushToast({
theme: "error",
message: getPomadeLoginFailureMessage(res.messages),
message: "Sorry, we were unable to log you in.",
})
}
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally {
loading = false
}
+1 -3
View File
@@ -16,7 +16,6 @@
children,
minimal = false,
hideProfile = false,
noShadow = false,
url,
...restProps
}: {
@@ -24,7 +23,6 @@
children: Snippet
minimal?: boolean
hideProfile?: boolean
noShadow?: boolean
url?: string
class?: string
} = $props()
@@ -36,7 +34,7 @@
let muted = $state($isEventMuted(event))
</script>
<div class="flex flex-col gap-2 {restProps.class}" class:shadow-md={!noShadow}>
<div class="flex flex-col gap-2 shadow-md {restProps.class}">
{#if muted}
<div class="flex items-center justify-between">
<div class="row-2 relative">
+1 -4
View File
@@ -1,11 +1,10 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
import NoteContentThread from "@app/components/NoteContentThread.svelte"
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
@@ -20,8 +19,6 @@
<NoteContentClassified {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentGoal {...props} />
{:else if props.event.kind === POLL}
<NoteContentPoll {...props} />
{:else}
<Content {...props} />
{/if}
@@ -9,10 +9,10 @@
<div class="flex items-start gap-4">
<CalendarEventDate event={props.event} />
<div class="flex grow flex-col">
<div class="flex flex-grow flex-col">
<CalendarEventHeader event={props.event} />
<div class="flex py-2 opacity-50">
<div class="h-px grow bg-base-content opacity-25"></div>
<div class="h-px flex-grow bg-base-content opacity-25"></div>
</div>
<Content {...props} />
</div>
+1 -4
View File
@@ -1,11 +1,10 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
const props: ComponentProps<typeof ContentMinimal> = $props()
@@ -20,8 +19,6 @@
<NoteContentMinimalClassified {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentMinimalGoal {...props} />
{:else if props.event.kind === POLL}
<NoteContentMinimalPoll {...props} />
{:else}
<ContentMinimal {...props} />
{/if}
@@ -17,7 +17,7 @@
</script>
<div class="flex flex-col">
<div class="flex grow flex-wrap justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-sm">{meta.title || meta.name}</p>
{#if !isNaN(start) && !isNaN(end)}
{@const startDateDisplay = formatTimestampAsDate(start)}
@@ -1,19 +0,0 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {derived} from "svelte/store"
import {POLL_RESPONSE} from "@welshman/util"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {deriveEvents} from "@app/core/state"
import {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof ContentMinimal> = $props()
const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [props.event.id]}])
const results = derived(responses, $responses => getPollResults(props.event, $responses))
</script>
<div class="flex flex-col gap-0">
<ContentMinimal {...props} />
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
</div>
-29
View File
@@ -1,29 +0,0 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {onMount} from "svelte"
import {request} from "@welshman/net"
import {POLL_RESPONSE} from "@welshman/util"
import PollVotes from "@app/components/PollVotes.svelte"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
onMount(() => {
if (!props.url) {
return
}
request({
relays: [props.url],
filters: [{kinds: [POLL_RESPONSE], "#e": [props.event.id]}],
})
})
</script>
<div class="flex flex-col gap-3">
<Content event={props.event} showEntire url={props.url} />
{#if props.url}
<PollVotes url={props.url} event={props.event} />
{/if}
</div>
-280
View File
@@ -1,280 +0,0 @@
<script lang="ts">
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent, POLL} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
import PlusCircle from "@assets/icons/add-circle.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.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 Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
import {DraftKey} from "@app/util/drafts"
import type {PollType} from "@app/util/polls"
type Option = {
id: string
value: string
}
type Values = {
title: string
pollType: PollType
endsAt?: number
options: Option[]
}
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
const back = () => history.back()
const addOption = () => {
options = [...options, {id: randomId(), value: ""}]
}
const removeOption = (id: string) => {
options = options.filter(option => option.id !== id)
}
const updateOption = (id: string, value: string) => {
options = options.map(option => (option.id === id ? {...option, value} : option))
}
const reorderOptions = (targetId: string) => {
if (!draggedOptionId) {
return
}
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
const targetIndex = options.findIndex(option => option.id === targetId)
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
return
}
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
}
const onDragStart = (e: DragEvent, id: string) => {
draggedOptionId = id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", id)
}
}
const onDragOver = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
}
const onDrop = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
draggedOptionId = undefined
}
const onDragEnd = () => {
draggedOptionId = undefined
}
const submit = async () => {
if (loading) return
if (!title.trim()) {
return pushToast({theme: "error", message: "Please provide a title for your poll."})
}
const nonEmptyOptions = removeUndefined(options.map(option => option.value.trim() || undefined))
if (nonEmptyOptions.length < 2) {
return pushToast({theme: "error", message: "Please provide at least two options."})
}
if (endsAt && endsAt <= now()) {
return pushToast({theme: "error", message: "End time must be in the future."})
}
const tags: string[][] = [
...nonEmptyOptions.map(option => ["option", randomId(), option]),
["polltype", pollType],
["relay", url],
]
if (endsAt) {
tags.push(["endsAt", String(endsAt)])
}
if (h) {
tags.push(["h", h])
}
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
const pollThunk = publishThunk({
relays: [url],
event: makeEvent(POLL, {content: title.trim(), tags}),
})
const error = await waitForThunkError(pollThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: pollThunk.event, protect})
}
} finally {
loading = false
}
}
let loading = $state(false)
let draggedOptionId = $state<string | undefined>()
let title = $state(initialValues?.title ?? "")
let pollType = $state<PollType>(initialValues?.pollType ?? "singlechoice")
let endsAt = $state<number | undefined>(initialValues?.endsAt)
let options = $state<Option[]>(
initialValues?.options ?? [
{id: randomId(), value: "Yes"},
{id: randomId(), value: "No"},
],
)
$effect(() => {
draftKey.set({title, pollType, endsAt, options})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Poll</ModalTitle>
<ModalSubtitle>Ask a question and collect votes right in the feed.</ModalSubtitle>
</ModalHeader>
<div class="col-8 relative">
<Field>
{#snippet label()}
<p>Question*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
class="grow"
type="text"
placeholder="What would you like to ask?" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Options*</p>
{/snippet}
{#snippet input()}
<div class="flex flex-col gap-2" role="list">
{#each options as option, index (option.id)}
<div
class="flex items-center gap-2"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, option.id)}
ondragover={e => onDragOver(e, option.id)}
ondrop={e => onDrop(e, option.id)}
ondragend={onDragEnd}>
<div class="cursor-move opacity-70" aria-label="Drag handle">
<Icon icon={HamburgerMenu} size={4} />
</div>
<label class="input input-bordered flex w-full items-center gap-2">
<input
value={option.value}
class="grow"
type="text"
placeholder={`Option ${index + 1}`}
oninput={e => updateOption(option.id, e.currentTarget.value)} />
</label>
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
<Icon icon={MinusCircle} size={4} />
</Button>
</div>
{/each}
<Button class="btn btn-outline btn-sm self-end" onclick={addOption}>
<Icon icon={PlusCircle} size={4} />
Add option
</Button>
</div>
{/snippet}
</Field>
<div class="flex flex-col gap-2">
<FieldInline>
{#snippet label()}
Poll type
{/snippet}
{#snippet input()}
<select class="select select-bordered w-full max-w-xs" bind:value={pollType}>
<option value="singlechoice">Single choice</option>
<option value="multiplechoice">Multiple choice</option>
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
Ends at
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={endsAt} />
{/snippet}
</FieldInline>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Create Poll</Spinner>
</Button>
</ModalFooter>
</Modal>
-34
View File
@@ -1,34 +0,0 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {makePollPath} from "@app/util/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const h = getTagValue("h", event.tags)
</script>
<Link
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
href={makePollPath(url, event.id)}>
<NoteContent {event} {url} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<CommentActions segment="polls" showActivity {url} {event} />
</div>
</Link>
-70
View File
@@ -1,70 +0,0 @@
<script lang="ts">
import {tweened} from "svelte/motion"
import type {TrustedEvent} from "@welshman/util"
import {noop} from "@welshman/lib"
import {stopPropagation} from "@lib/html"
import {getPollType, isPollClosed} from "@app/util/polls"
type Props = {
event: TrustedEvent
option: {id: string; label: string}
results: {voters: number; options: {id: string; votes: number}[]}
selectedIds: string[]
setSingleChoice: (id: string) => void
toggleMultipleChoice: (id: string) => void
}
const {event, option, results, selectedIds, setSingleChoice, toggleMultipleChoice}: Props =
$props()
const pollType = getPollType(event)
const closed = isPollClosed(event)
const selected = $derived(
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
)
const onselect = () =>
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
const tweenedVotes = tweened(votes, {duration: 300})
const tweenedMax = tweened(maxVotes, {duration: 300})
$effect(() => {
tweenedVotes.set(votes)
})
$effect(() => {
tweenedMax.set(maxVotes)
})
</script>
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
<div class="flex items-center justify-between gap-2">
<label class="flex min-w-0 grow items-center gap-2">
{#if !closed}
{#if pollType === "singlechoice"}
<input
name={event.id}
type="radio"
class="radio radio-primary radio-sm"
checked={selected}
onclick={stopPropagation(noop)}
onchange={onselect} />
{:else}
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={selected}
onclick={stopPropagation(noop)}
onchange={onselect} />
{/if}
{/if}
<span class="truncate">{option.label}</span>
</label>
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
</div>
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
</div>
-127
View File
@@ -1,127 +0,0 @@
<script lang="ts">
import {onDestroy} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {POLL_RESPONSE} from "@welshman/util"
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
import {formatTimestampRelative} from "@welshman/lib"
import {deriveEvents} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {makePollResponse} from "@app/core/commands"
import PollOption from "@app/components/PollOption.svelte"
import {
getPollEndsAt,
getPollOptions,
getPollResponseSelections,
getPollResults,
getPollType,
isPollClosed,
} from "@app/util/polls"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [event.id]}])
const pollType = getPollType(event)
const options = getPollOptions(event)
const closed = isPollClosed(event)
const endsAt = getPollEndsAt(event)
const publishDelay = pollType === "multiplechoice" ? 10_000 : undefined
const getOwnResponse = (responses: TrustedEvent[]) => {
let latest: TrustedEvent | undefined
for (const response of responses) {
if (response.pubkey !== $pubkey) {
continue
}
if (!latest || response.created_at > latest.created_at) {
latest = response
}
}
return latest
}
const publishSelection = (selection: string[]) => {
if (activeThunk) {
abortThunk(activeThunk)
}
if (selection.length === 0) {
activeThunk = undefined
return
}
activeThunk = publishThunk({
relays: [url],
event: makePollResponse({event, selectedIds: selection}),
delay: publishDelay,
})
}
const publishCurrentSelection = () => {
const selection = pollType === "singlechoice" ? selectedIds.slice(0, 1) : selectedIds
if (selection.length === 0) {
return pushToast({theme: "error", message: "Please select at least one option."})
}
publishSelection(selection)
}
const results = $derived(getPollResults(event, $responses))
const ownResponse = $derived(getOwnResponse($responses))
const setSingleChoice = (id: string) => {
selectedIds = [id]
publishCurrentSelection()
}
const toggleMultipleChoice = (id: string) => {
selectedIds = selectedIds.includes(id)
? selectedIds.filter(selectedId => selectedId !== id)
: [...selectedIds, id]
publishCurrentSelection()
}
let selectedIds = $state<string[]>([])
let activeThunk: ReturnType<typeof publishThunk> | undefined
$effect(() => {
if (ownResponse) {
selectedIds = getPollResponseSelections(ownResponse, pollType)
}
})
onDestroy(() => {
if (activeThunk) {
abortThunk(activeThunk)
}
})
</script>
<div class="flex flex-col gap-2">
{#each options as option (option.id)}
<PollOption {event} {option} {results} {selectedIds} {setSingleChoice} {toggleMultipleChoice} />
{/each}
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm opacity-75">
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
{#if endsAt}
{#if closed}
• Ended {formatTimestampRelative(endsAt)}
{:else}
• Ends {formatTimestampRelative(endsAt)}
{/if}
{/if}
</div>
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
</div>
</div>
+12 -6
View File
@@ -32,14 +32,18 @@
</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-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
<Divider />
{/if}
<div class="flex flex-col">
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
<div>
<PrimaryNavItem
title="Settings"
href="/settings/profile"
prefix="/settings"
class="tooltip-right">
{#if $userProfile?.picture}
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
{:else}
@@ -49,10 +53,11 @@
<PrimaryNavItem
title="Messages"
onclick={chatHandler}
class="tooltip-right"
notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem>
<PrimaryNavItem title="Search" href="/people">
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
<ImageIcon alt="Search" src={Magnifier} size={8} />
</PrimaryNavItem>
</div>
@@ -62,10 +67,11 @@
{@render children?.()}
<!-- a little extra something for ios -->
<div class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-(--saib) bg-base-100 md:hidden">
<div
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
</div>
<div
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2">
<div class="flex gap-2 sm:gap-6">
<PrimaryNavItem title="Search" href="/people">
@@ -1,5 +1,5 @@
<script lang="ts">
import {deriveRelayDisplay} from "@welshman/app"
import {displayRelayUrl} from "@welshman/util"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import {makeSpacePath, goToSpace} from "@app/util/routes"
@@ -12,13 +12,11 @@
const {url}: Props = $props()
const onClick = () => goToSpace(url)
const display = $derived(deriveRelayDisplay(url))
</script>
<PrimaryNavItem
onclick={onClick}
title={$display}
title={displayRelayUrl(url)}
class="tooltip-right"
notification={$notifications.has(makeSpacePath(url))}>
<RelayIcon {url} size={10} class="rounded-full" />
+3 -2
View File
@@ -12,7 +12,7 @@
const itemHeight = 56
const navPadding = 8 * itemHeight
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
</script>
@@ -23,7 +23,7 @@
{#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} />
{:else}
<PrimaryNavItem title="Home" href="/home">
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
</PrimaryNavItem>
<Divider />
@@ -33,6 +33,7 @@
<PrimaryNavItem
href="/spaces"
title="All Spaces"
class="tooltip-right"
prefix="no-highlight"
notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} />
+3 -3
View File
@@ -5,11 +5,11 @@
import type {Filter} from "@welshman/util"
import {deriveEventsDesc, deriveEventsById} from "@welshman/store"
import {formatTimestampRelative} from "@welshman/lib"
import {NOTE, ROOMS, COMMENT, MESSAGE} from "@welshman/util"
import {NOTE, ROOMS, COMMENT} from "@welshman/util"
import {repository, loadRelayList} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
import {deriveGroupList, getSpaceUrlsFromGroupList} from "@app/core/state"
import {deriveGroupList, getSpaceUrlsFromGroupList, MESSAGE_KINDS} from "@app/core/state"
import {goToEvent} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
@@ -36,7 +36,7 @@
load({
filters: [
{authors: [pubkey], kinds: [ROOMS]},
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, MESSAGE]},
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, ...MESSAGE_KINDS]},
],
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
})
+12 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type {Profile} from "@welshman/util"
import {makeProfile} from "@welshman/util"
import {getTag, makeProfile} from "@welshman/util"
import {pubkey, profilesByPubkey, waitForThunkError} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import {errorMessage} from "@lib/util"
@@ -10,18 +10,26 @@
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {clearModals} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {updateProfile} from "@app/core/commands"
const profile = $profilesByPubkey.get($pubkey!) || makeProfile()
const initialValues = {profile}
const shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || [])
const initialValues = {profile, shouldBroadcast}
const back = () => history.back()
const onsubmit = async ({profile}: {profile: Profile}) => {
const onsubmit = async ({
profile,
shouldBroadcast,
}: {
profile: Profile
shouldBroadcast: boolean
}) => {
loading = true
try {
const error = await waitForThunkError(updateProfile({profile}))
const error = await waitForThunkError(updateProfile({profile, shouldBroadcast}))
if (error) {
pushToast({
+22 -1
View File
@@ -6,6 +6,7 @@
import MapPoint from "@assets/icons/map-point.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
@@ -16,6 +17,7 @@
type Values = {
profile: Profile
shouldBroadcast: boolean
}
type Props = {
@@ -75,7 +77,7 @@
{/snippet}
{#snippet input()}
<textarea
class="textarea textarea-bordered leading-4 w-full"
class="textarea textarea-bordered leading-4"
rows="5"
bind:value={values.profile.about}></textarea>
{/snippet}
@@ -102,6 +104,25 @@
{/snippet}
</Field>
{/if}
{#if !isSignup}
<FieldInline>
{#snippet label()}
<p>Broadcast Profile</p>
{/snippet}
{#snippet input()}
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={values.shouldBroadcast} />
{/snippet}
{#snippet info()}
<p>
If enabled, changes will be published to the broader nostr network in addition to spaces
you are a member of.
</p>
{/snippet}
</FieldInline>
{/if}
</ModalBody>
<ModalFooter>
{@render footer()}
+2 -2
View File
@@ -25,10 +25,10 @@
<div class="flex flex-col gap-2">
{#each spaceUrls as url (url)}
<div class="card2 bg-alt flex flex-row items-center gap-2">
<div class="shrink-0">
<div class="flex-shrink-0">
<RelayIcon {url} size={12} />
</div>
<div class="flex grow flex-col">
<div class="flex flex-grow flex-col">
<RelayName {url} />
<div class="text-sm opacity-75">
{url}
+5 -21
View File
@@ -23,7 +23,7 @@
import Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte"
import ReportDetails from "@app/components/ReportDetails.svelte"
import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
import {REACTION_KINDS} from "@app/core/state"
import {pushModal} from "@app/util/modal"
interface Props {
@@ -33,7 +33,6 @@
url?: string
reactionClass?: string
noTooltip?: boolean
innerEvent?: TrustedEvent
children?: Snippet
}
@@ -44,36 +43,23 @@
url = "",
reactionClass = "",
noTooltip = false,
innerEvent = undefined,
children,
}: Props = $props()
const eventIds = innerEvent ? [event.id, innerEvent.id] : [event.id]
const reports = deriveArray(
deriveEventsById({repository, filters: [{kinds: [REPORT], "#e": [event.id]}]}),
)
const reactions = deriveArray(
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": eventIds}]}),
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": [event.id]}]}),
)
const zaps = deriveArray(
deriveItemsByKey<Zap>({
repository,
getKey: zap => zap.response.id,
filters: [{kinds: [ZAP_RESPONSE], "#e": eventIds}],
eventToItem: (response: TrustedEvent) => {
const zap = getValidZap(response, event)
if (zap) {
return zap
}
if (innerEvent) {
return getValidZap(response, innerEvent)
}
},
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
}),
)
@@ -92,8 +78,6 @@
}
}
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const onReportClick = () => pushModal(ReportDetails, {url, event})
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
@@ -134,7 +118,7 @@
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
<div class="flex min-w-0 flex-wrap gap-2">
{#if url && $reports.length > 0 && $userIsAdmin}
{#if url && $reports.length > 0}
<button
type="button"
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
+1 -1
View File
@@ -121,6 +121,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
</ModalFooter>
</Modal>
+2 -2
View File
@@ -26,8 +26,8 @@
type="button"
class="btn font-normal flex h-[unset] w-full flex-nowrap py-4 text-left items-start justify-between"
{onclick}>
<div class="flex grow flex-row items-start gap-4">
<div class="flex h-7 w-7 shrink-0 items-center justify-center">
<div class="flex flex-grow flex-row items-start gap-4">
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center">
<Icon {icon} />
</div>
<div class="flex flex-col gap-1">
+1 -1
View File
@@ -23,7 +23,7 @@
<div class="relative">
<div class="avatar relative">
<div
class="center flex! h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
class="center !flex h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
<RelayIcon {url} />
</div>
</div>
+6 -34
View File
@@ -12,29 +12,18 @@
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {onDestroy, onMount} from "svelte"
type Values = {
content?: string | object
}
type Props = {
url?: string
h?: string
content?: string
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
}
let {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
const draftKey = url || h ? new DraftKey<Values>(`room:${url ?? ""}:${h ?? ""}`) : undefined
if (!initialValues) {
initialValues = draftKey?.get()
}
const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props()
const autofocus = !isMobile
@@ -72,29 +61,12 @@
onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run()
}
const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true})
let popover: Instance | undefined = $state()
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
content,
submit,
uploading,
onChange,
aggressive: true,
})
$effect(() => {
draftKey?.set({content})
})
onMount(async () => {
const ed = await editor
@@ -132,8 +104,8 @@
</Button>
</Tippy>
</div>
<div class="chat-editor grow overflow-hidden">
<EditorContent {autofocus} {editor} />
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
+26 -31
View File
@@ -22,7 +22,6 @@
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Tooltip from "@lib/components/Tooltip.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
@@ -207,43 +206,43 @@
<strong class="text-lg">Room Permissions</strong>
<div class="flex gap-2 flex-wrap">
{#if $room?.isRestricted}
<Tooltip content="Only members can send messages.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={Microphone} /> Restricted
</Button>
</Tooltip>
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="Only members can send messages.">
<Icon size={4} icon={Microphone} /> Restricted
</Button>
{/if}
{#if $room?.isPrivate}
<Tooltip content="Only members can view messages.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={Lock} /> Private
</Button>
</Tooltip>
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="Only members can view messages.">
<Icon size={4} icon={Lock} /> Private
</Button>
{/if}
{#if $room?.isHidden}
<Tooltip content="This room is not visible to non-members.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={EyeClosed} /> Hidden
</Button>
</Tooltip>
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="This room is not visible to non-members.">
<Icon size={4} icon={EyeClosed} /> Hidden
</Button>
{/if}
{#if $room?.isClosed}
<Tooltip content="Requests to join this room will be ignored.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={MinusCircle} /> Closed
</Button>
</Tooltip>
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="Requests to join this room will be ignored.">
<Icon size={4} icon={MinusCircle} /> Closed
</Button>
{/if}
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
<Tooltip content="This room has no additional access controls.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={Eye} /> Public
</Button>
</Tooltip>
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="This room has no additional access controls.">
<Icon size={4} icon={Eye} /> Public
</Button>
{/if}
</div>
</div>
{#if $members !== undefined && $members.length > 0}
{#if $members.length > 0}
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<span>Members:</span>
@@ -251,10 +250,6 @@
</div>
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
</div>
{:else if $members === undefined}
<div class="card2 card2-sm bg-base-200 flex items-center gap-4">
<span class="text-error">Member list not available from this relay</span>
</div>
{/if}
<div class="card2 card2-sm bg-alt col-4">
<strong class="text-lg">Room Settings</strong>
+1 -1
View File
@@ -131,7 +131,7 @@
<p>Icon</p>
{/snippet}
{#snippet input()}
<div class="flex grow items-center justify-between gap-4">
<div class="flex flex-grow items-center justify-between gap-4">
{#if imagePreview}
<div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span>
+17 -37
View File
@@ -1,16 +1,8 @@
<script lang="ts">
import cx from "classnames"
import {readable} from "svelte/store"
import {
hash,
gte,
now,
displayList,
formatTimestampAsTime,
formatTimestampAsDate,
} from "@welshman/lib"
import {hash, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {MESSAGE, COMMENT, getTag} from "@welshman/util"
import {MESSAGE, COMMENT} from "@welshman/util"
import {
thunks,
pubkey,
@@ -35,7 +27,7 @@
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
import RoomItemContent from "@app/components/RoomItemContent.svelte"
import {colors, ENABLE_ZAPS, deriveEventsForUrl, deriveEvent} from "@app/core/state"
import {colors, ENABLE_ZAPS, deriveEventsForUrl} from "@app/core/state"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {getRoomItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
@@ -45,7 +37,7 @@
event: TrustedEvent
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
addSpaceBelow?: boolean
inert?: boolean
canEdit: (event: TrustedEvent) => boolean
onEdit: (event: TrustedEvent) => void
}
@@ -55,7 +47,7 @@
event,
replyTo = undefined,
showPubkey = false,
addSpaceBelow = false,
inert = false,
canEdit,
onEdit,
}: Props = $props()
@@ -66,15 +58,7 @@
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
const qTag = getTag("q", event.tags)
const isQuoteOnly = Boolean(
gte(qTag?.length, 2) && event.content.trim().match(/^nostr:n(event|addr)1\w+\s*$/),
)
const innerComments = isQuoteOnly
? deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [qTag![1]]}])
: readable([])
const innerEvent = isQuoteOnly ? deriveEvent(qTag![1], [url]) : readable(undefined)
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [event.id]}])
const reply = () => replyTo!(event)
const edit = canEdit(event) ? () => onEdit(event) : undefined
@@ -92,23 +76,20 @@
<TapTarget
data-event={event.id}
{onTap}
class={cx(
"group relative flex w-full cursor-default flex-col px-2 py-0.5 text-left hover:bg-base-100/50",
{"mt-1.5": showPubkey, "mb-1.5": addSpaceBelow},
)}>
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
<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">
<Button onclick={openProfile} class="flex items-start">
<ProfileCircle
pubkey={event.pubkey}
class="border border-solid border-base-content"
size={8} />
</Button>
{:else}
<div class="w-8 shrink-0"></div>
<div class="w-8 min-w-8 max-w-8"></div>
{/if}
<div class="min-w-0 grow pr-1">
<div class="min-w-0 flex-grow pr-1">
{#if showPubkey}
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
@@ -125,7 +106,7 @@
</div>
{/if}
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<RoomItemContent {url} event={$innerEvent ?? event} />
<RoomItemContent {url} {event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if}
@@ -138,10 +119,9 @@
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right"
innerEvent={$innerEvent} />
{#if path && $innerComments.length > 0}
{@const pubkeys = $innerComments.map(e => e.pubkey)}
reactionClass="tooltip-right" />
{#if path && $comments.length > 0}
{@const pubkeys = $comments.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} commented`}
@@ -153,14 +133,14 @@
"btn-primary": isOwn,
})}>
<Icon icon={ReplyAlt} />
<span>{$innerComments.length} comment{$innerComments.length === 1 ? "" : "s"}</span>
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
</Link>
</div>
{/if}
</div>
{#if !isMobile}
<button
class="join absolute right-2 top-0.5 border border-solid border-neutral text-xs opacity-0 transition-all pr-2"
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<RoomItemZapButton {url} {event} />
+3 -4
View File
@@ -8,17 +8,16 @@
import {getRoomItemPath} from "@app/util/routes"
const props: ComponentProps<typeof NoteContent> = $props()
const path = getRoomItemPath(props.url!, props.event)
const minLength = 5000
const maxLength = 5500
</script>
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
{#if path && !isMobile}
<Link href={path}>
<NoteContent {...props} {minLength} {maxLength} />
<NoteContent {...props} />
</Link>
{:else}
<NoteContent {...props} {minLength} {maxLength} />
<NoteContent {...props} />
{/if}
</div>
@@ -0,0 +1,18 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getPubkeyTagValues} from "@welshman/util"
import ProfileLink from "@app/components/ProfileLink.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
</script>
{#each getPubkeyTagValues(event.tags) as pubkey}
<div class="py-1 text-center text-xs opacity-75">
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> left the room
</div>
{/each}
+26 -36
View File
@@ -73,44 +73,34 @@
</ModalSubtitle>
</ModalHeader>
<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 relay</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 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<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">
<li>
<Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
{#each $members as pubkey (pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<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">
<li>
<Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
</div>
{/each}
{/if}
</div>
{/each}
</div>
</ModalBody>
<ModalFooter>
-5
View File
@@ -56,11 +56,6 @@
}
const onSubmit = async () => {
if (!$spaceMembers) {
addMembers()
return
}
const pubkeysSnapshot = $state.snapshot(pubkeys)
const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey))
+2 -2
View File
@@ -11,8 +11,8 @@
const {url, h, ...props}: Props = $props()
</script>
<div class="flex grow items-center justify-between gap-4 {props.class}">
<div class="flex items-center gap-2">
<div class="flex flex-grow items-center justify-between gap-4 {props.class}">
<div class="flex items-center gap-3">
<RoomImage {url} {h} />
<div class="min-w-0 overflow-hidden text-ellipsis">
<RoomName {url} {h} />
+1 -4
View File
@@ -120,7 +120,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input type="email" bind:value={email} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
@@ -134,9 +134,6 @@
<input type="password" bind:value={password} />
</label>
{/snippet}
{#snippet info()}
Must be at least 12 characters long.
{/snippet}
</FieldInline>
</ModalBody>
<ModalFooter>
+1 -1
View File
@@ -17,7 +17,7 @@
const profile = getKey<Profile>("signup.profile")!
const initialValues = {profile}
const initialValues = {profile, shouldBroadcast: false}
const back = () => history.back()
+1 -1
View File
@@ -27,7 +27,7 @@
<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="ellipsize whitespace-nowrap flex flex-grow items-center justify-between gap-4">
<div class="flex flex-col">
<div class="flex gap-2 items-center">
{@render title?.()}
+1 -1
View File
@@ -42,7 +42,7 @@
<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">
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>
+1 -1
View File
@@ -134,7 +134,7 @@
<p>Icon</p>
{/snippet}
{#snippet input()}
<div class="flex items-center gap-4 justify-between grow">
<div class="flex items-center gap-4 justify-between flex-grow">
{#if imagePreview}
<div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span>
+24 -78
View File
@@ -3,9 +3,7 @@
import {sleep} from "@welshman/lib"
import {request} from "@welshman/net"
import {displayRelayUrl, getTagValue, 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 Spinner from "@lib/components/Spinner.svelte"
import Field from "@lib/components/Field.svelte"
@@ -25,72 +23,36 @@
const {url} = $props()
const authError = deriveRelayAuthError(url)
let networkError = $state(false)
const isExplicitAuthError = $derived(
$authError &&
!(
$authError.toLowerCase().includes("failed") ||
$authError.toLowerCase().includes("timeout") ||
$authError.toLowerCase().includes("network")
),
)
const isGenericError = $derived(networkError || ($authError && !isExplicitAuthError))
const back = () => history.back()
const copyInvite = () => clip(invite)
const shareInvite = async () => {
if (!canShare) return
try {
await Share.share({url: invite})
} catch (e) {
console.error(e)
}
}
let canShare = $state(false)
let claim = $state("")
let loading = $state(true)
let invite = $state("")
$effect(() => {
const relay = displayRelayUrl(url)
const params = new URLSearchParams({r: relay, c: claim}).toString()
invite = PLATFORM_URL + "/join?" + params
})
onMount(async () => {
try {
const {value} = await Share.canShare()
canShare = value
} catch {
canShare = false
}
const [[event]] = await Promise.all([
request({
relays: [url],
autoClose: true,
signal: AbortSignal.timeout(3000),
filters: [{kinds: [RELAY_INVITE]}],
}),
sleep(2000),
])
try {
const [[event]] = await Promise.all([
request({
relays: [url],
autoClose: true,
signal: AbortSignal.timeout(10000),
filters: [{kinds: [RELAY_INVITE]}],
}),
sleep(2000),
])
claim = getTagValue("claim", event?.tags || []) || ""
} catch (err) {
claim = ""
if (
(err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError")) ||
!navigator.onLine
) {
networkError = true
}
} finally {
loading = false
}
claim = getTagValue("claim", event?.tags || []) || ""
loading = false
})
</script>
@@ -108,36 +70,20 @@
<p class="center">
<Spinner {loading}>Requesting an invite link...</Spinner>
</p>
{:else if isGenericError}
<p class="center text-center">
Unable to reach the relay. Please check your connection and try again.
</p>
{:else if isExplicitAuthError}
{:else if $authError}
<p class="center">Oops! It looks like you're not a member of this relay.</p>
{:else}
<div class="flex flex-col items-center gap-6">
<div class="w-48">
<QRCode code={invite} />
</div>
<QRCode code={invite} />
<Field>
{#snippet input()}
<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"
onclick={shareInvite}>
<Icon icon={Upload} />
</Button>
{/if}
<label class="input input-bordered flex min-w-0 flex-1 items-center gap-2">
<Icon icon={LinkRound} class="shrink-0" />
<input bind:value={invite} class="min-w-0 flex-1 truncate" type="text" readonly />
<Button class="shrink-0" onclick={copyInvite}>
<Icon icon={Copy} />
</Button>
</label>
</div>
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={LinkRound} />
<input bind:value={invite} class="grow" type="text" />
<Button onclick={copyInvite}>
<Icon icon={Copy} />
</Button>
</label>
{/snippet}
{#snippet info()}
<p>
@@ -154,6 +100,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
</ModalFooter>
</Modal>
+38 -50
View File
@@ -112,58 +112,46 @@
{/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}
{#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>
{/each}
{/if}
</div>
{/each}
</div>
</ModalBody>
<ModalFooter>
+10 -24
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {derived} from "svelte/store"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import {deriveRelay, deriveRelayDisplay, createSearch, pubkey} from "@welshman/app"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {deriveRelay, createSearch, pubkey} from "@welshman/app"
import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
@@ -17,7 +17,6 @@
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
@@ -65,13 +64,11 @@
const {url} = $props()
const relay = deriveRelay(url)
const display = deriveRelayDisplay(url)
const chatPath = makeSpacePath(url, "chat")
const goalsPath = makeSpacePath(url, "goals")
const threadsPath = makeSpacePath(url, "threads")
const classifiedsPath = makeSpacePath(url, "classifieds")
const calendarPath = makeSpacePath(url, "calendar")
const pollsPath = makeSpacePath(url, "polls")
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const otherVoiceRooms = deriveOtherVoiceRooms(url)
@@ -139,14 +136,12 @@
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
<div class="shrink-0">
<div class="flex-shrink-0">
<Button
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="flex items-center gap-1 relative tooltip tooltip-right"
data-tip={$display}>
<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"
@@ -180,11 +175,7 @@
<li>
<Button onclick={showMembers}>
<Icon icon={UserRounded} />
{#if $members === undefined}
View Members
{:else}
View Members ({$members.length})
{/if}
View Members ({$members.length})
</Button>
</li>
{#if $userIsAdmin}
@@ -266,21 +257,16 @@
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(POLL)}
<SecondaryNavItem href={pollsPath}>
<Icon icon={Revote} /> Polls
</SecondaryNavItem>
{/if}
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
<div class="h-2 shrink-0"></div>
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2 shrink-0"></div>
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
@@ -299,7 +285,7 @@
<SpaceMenuRoomItem {url} {h} />
{/each}
{#if $otherVoiceRooms.length > 0}
<div class="h-2 shrink-0"></div>
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
@@ -312,11 +298,11 @@
</SecondaryNavItem>
{/if}
{/if}
<div class="h-5 shrink-0"></div>
<div class="h-5 flex-shrink-0"></div>
</div>
</SecondaryNavSection>
<div
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
class="flex 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-neutral btn-sm h-10" onclick={showDetail}>
<SocketStatusIndicator {url} />
+1 -2
View File
@@ -24,13 +24,12 @@
const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
const roomName = $derived($room?.name || h)
</script>
{#if roomType === RoomType.Voice}
<VoiceRoomItem {url} {h} {replaceState} {notification} />
{:else}
<SecondaryNavItem href={path} title={roomName} {replaceState} {notification}>
<SecondaryNavItem href={path} {replaceState} {notification}>
<RoomNameWithImage {url} {h} />
{#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
+1 -1
View File
@@ -46,7 +46,7 @@
</div>
{/if}
{#if Array.isArray(supported_nips)}
<p class="badge badge-neutral text-wrap h-auto">
<p class="badge badge-neutral">
<span class="ellipsize">Supported NIPs: {supported_nips.join(", ")}</span>
</p>
{/if}
+69 -67
View File
@@ -130,74 +130,76 @@
}
</script>
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
<Icon size={4} icon={Magnifier} />
</button>
{#if show}
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
<div class="fixed top-sai right-sai left-content z-feature p-2">
<div
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
transition:fly={{y: -40, duration: 150}}>
<div class="flex justify-between">
<strong>Search</strong>
<Button onclick={clear}>
<Icon icon={CloseCircle} />
</Button>
</div>
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:this={input}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder={h ? "Search this room..." : "Search this space..."}
oninput={onInput} />
</label>
<div class="max-h-[65vh] overflow-y-auto">
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
{#if !term}
<p class="text-sm opacity-70">
{h ? "Search for content in this room." : "Search for content in this space."}
</p>
{:else if loading}
<p class="text-sm opacity-70">Searching...</p>
{:else if eventsByAge.size === 0}
<p class="text-sm opacity-70">No results found.</p>
{:else}
<div class="col-2">
{#each eventsByAge as [key, events] (key)}
<div class="col-2">
<p class="text-xs uppercase tracking-wide opacity-60">
{#if key === "day"}
Last 24 Hours
{:else if key === "week"}
Last 7 Days
{:else}
Older
{/if}
</p>
<div>
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
<Icon size={4} icon={Magnifier} />
</button>
{#if show}
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
<div class="fixed cw top-0 right-0 z-feature p-2">
<div
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
transition:fly={{y: -40, duration: 150}}>
<div class="flex justify-between">
<strong>Search</strong>
<Button onclick={clear}>
<Icon icon={CloseCircle} />
</Button>
</div>
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:this={input}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder={h ? "Search this room..." : "Search this space..."}
oninput={onInput} />
</label>
<div class="max-h-[65vh] overflow-y-auto">
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
{#if !term}
<p class="text-sm opacity-70">
{h ? "Search for content in this room." : "Search for content in this space."}
</p>
{:else if loading}
<p class="text-sm opacity-70">Searching...</p>
{:else if eventsByAge.size === 0}
<p class="text-sm opacity-70">No results found.</p>
{:else}
<div class="col-2">
{#each eventsByAge as [key, events] (key)}
<div class="col-2">
{#each events as event (event.id)}
<button
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
onclick={() => onRoomSearchResultClick(event)}>
<p class="line-clamp-2 text-sm">
{event.content.trim() || "(No text content)"}
</p>
<div class="row-2 text-xs opacity-70">
<span>{getAgeLabel(event.created_at)}</span>
<span>{formatTimestampAsDate(event.created_at)}</span>
</div>
</button>
{/each}
<p class="text-xs uppercase tracking-wide opacity-60">
{#if key === "day"}
Last 24 Hours
{:else if key === "week"}
Last 7 Days
{:else}
Older
{/if}
</p>
<div class="col-2">
{#each events as event (event.id)}
<button
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
onclick={() => onRoomSearchResultClick(event)}>
<p class="line-clamp-2 text-sm">
{event.content.trim() || "(No text content)"}
</p>
<div class="row-2 text-xs opacity-70">
<span>{getAgeLabel(event.created_at)}</span>
<span>{formatTimestampAsDate(event.created_at)}</span>
</div>
</button>
{/each}
</div>
</div>
</div>
{/each}
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
{/if}
</div>
+1 -1
View File
@@ -30,7 +30,7 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} />
+24 -73
View File
@@ -1,14 +1,13 @@
<script lang="ts">
import {writable} from "svelte/store"
import {makeEvent, THREAD} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -19,23 +18,15 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = {
content?: string | object
title?: string
}
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
@@ -45,7 +36,7 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) return
if ($uploading) return
if (!title) {
return pushToast({
@@ -66,62 +57,25 @@
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]]
loading = true
try {
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
const threadThunk = publishThunk({
relays: [url],
event: makeEvent(THREAD, {content, tags}),
})
const error = await waitForThunkError(threadThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: threadThunk.event, protect})
}
} finally {
loading = false
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(THREAD, {content, tags}),
})
history.back()
}
let loading = $state(false)
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title = $state(initialValues?.title ?? "")
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "What's on your mind?",
content,
})
$effect(() => {
draftKey.update({title, content})
})
let title: string = $state("")
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -152,7 +106,7 @@
<p>Message*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
@@ -160,8 +114,7 @@
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
@@ -171,12 +124,10 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Thread</Spinner>
</Button>
<Button type="submit" class="btn btn-primary">Create Thread</Button>
</ModalFooter>
</Modal>
+7 -74
View File
@@ -1,98 +1,28 @@
<script lang="ts">
import {parse, renderAsHtml} from "@welshman/content"
import Close from "@assets/icons/close.svg?dataurl"
import {fly} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {toast, popToast} from "@app/util/toast"
let touchStartY = 0
let touchStartTime = 0
let dragY = $state(0)
let isSettling = $state(false)
let containerEl = $state<HTMLDivElement | undefined>(undefined)
$effect(() => {
if ($toast) {
dragY = 0
isSettling = false
}
})
$effect(() => {
if (!containerEl) return
containerEl.addEventListener("touchmove", onTouchMove, {passive: false})
return () => containerEl!.removeEventListener("touchmove", onTouchMove)
})
const onActionClick = () => {
$toast!.action!.onclick()
popToast($toast!.id)
}
const onClose = () => popToast($toast!.id)
const onTouchStart = (e: TouchEvent) => {
touchStartY = e.touches[0].clientY
touchStartTime = Date.now()
dragY = 0
isSettling = false
}
const onTouchMove = (e: TouchEvent) => {
const delta = e.touches[0].clientY - touchStartY
if (delta < 0) {
e.preventDefault()
isSettling = false
dragY = delta
} else {
dragY = 0
}
}
const onTouchEnd = (e: TouchEvent) => {
const delta = e.changedTouches[0].clientY - touchStartY
const duration = Date.now() - touchStartTime
const isQuickFlick = duration < 400 && delta < 0
const isSlowDismiss = delta < -40
if (isQuickFlick || isSlowDismiss) {
dragY = 0
popToast($toast!.id)
} else {
isSettling = true
dragY = 0
setTimeout(() => {
isSettling = false
}, 200)
}
}
</script>
{#if $toast}
{@const theme = $toast.theme || "info"}
<div
bind:this={containerEl}
transition:fly={{y: -20}}
class="fixed z-toast top-[calc(var(--sait)+0.5rem)] left-[calc(var(--sail)+0.5rem)] right-[calc(var(--sair)+0.5rem)] flex flex-col gap-2 md:right-4 md:bottom-4 md:top-auto md:left-auto md:w-80"
style={dragY !== 0 || isSettling
? `transform: translateY(${dragY}px)${isSettling ? "; transition: transform 200ms ease-out" : ""}`
: ""}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}>
<div transition:fly class="bottom-sai right-sai toast z-toast">
{#key $toast.id}
<div
role="alert"
class="alert relative flex justify-center whitespace-normal text-left"
class="alert flex justify-center whitespace-normal text-left"
class:bg-base-100={theme === "info"}
class:text-base-content={theme === "info"}
class:alert-error={theme === "error"}>
<Button
class="absolute -top-2 -right-2 btn btn-circle btn-neutral btn-xs hidden md:inline-flex flex justify-center items-center"
onclick={onClose}>
<Icon icon={Close} size={4} />
</Button>
<p class="md:pr-6" class:welshman-content-error={theme === "error"}>
<p class:welshman-content-error={theme === "error"}>
{#if $toast.message}
{@html renderAsHtml(parse({content: $toast.message}))}
{#if $toast.action}
@@ -105,6 +35,9 @@
<Component toast={$toast} {...props} />
{/if}
</p>
<Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}>
<Icon icon={CloseCircle} />
</Button>
</div>
{/key}
</div>
+59 -80
View File
@@ -6,96 +6,73 @@
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import VideoCallTile from "@app/components/VideoCallTile.svelte"
import VideoCallVideo from "@app/components/VideoCallVideo.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {get} from "svelte/store"
import {
VideoCallLayout,
isDesktopLayout,
toggleVideoPrimaryTile,
videoCallLayout,
videoCallViewportSync,
ViewportSize,
currentVoiceSession,
currentVoiceRoom,
videoCallLayoutRevision,
videoPrimaryTileKey,
} from "@app/call/video"
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores"
toggleVideoPrimaryTile,
pubkeyFromLiveKitIdentity,
} from "@app/voice"
type Variant = "mobile" | "desktop-split" | "desktop-full"
type Props = {
layout: VideoCallLayout
mobile?: boolean
variant: Variant
url: string
h: string
visible?: boolean
class?: string
}
type VideoTileData = {
type Tile = {
identity: string
isLocal: boolean
trackSid: string
track: Track | undefined
attachable: Track | undefined
source: Track.Source.Camera | Track.Source.ScreenShare
}
type TileLayout = "spotlight" | "default" | "strip"
const {layout, mobile = false, url, h, class: className = ""}: Props = $props()
const {variant, url, h, visible = true, class: className = ""}: Props = $props()
$effect(() => {
const currentLayout = isDesktopLayout.current ? ViewportSize.Desktop : ViewportSize.Mobile
const {previousLayout} = videoCallViewportSync
if (previousLayout === undefined) {
videoCallViewportSync.previousLayout = currentLayout
return
}
if (previousLayout === currentLayout) return
const p = get(videoCallLayout)
if (previousLayout === ViewportSize.Desktop && currentLayout === ViewportSize.Mobile) {
if (p === VideoCallLayout.Split) videoCallLayout.set(VideoCallLayout.Video)
} else if (previousLayout === ViewportSize.Mobile && currentLayout === ViewportSize.Desktop) {
if (p === VideoCallLayout.Chat) videoCallLayout.set(VideoCallLayout.Split)
}
videoCallViewportSync.previousLayout = currentLayout
})
const roomMatches = $derived($currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h)
const isViewingCurrentCallRoom = $derived(
$currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
)
const showPanel = $derived(visible && roomMatches)
const showVideoContent = $derived(
isViewingCurrentCallRoom &&
(mobile
? layout === VideoCallLayout.Video
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
)
const videoTiles = $derived.by(() => {
const tiles = $derived.by(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes
$videoCallLayoutRevision
const session = $currentVoiceSession
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
return []
}
const room = session.room
const videoTiles: VideoTileData[] = []
const user = room.localParticipant
const out: Tile[] = []
const lp = room.localParticipant
if (session.cameraOn) {
const localPub = user.getTrackPublication(Track.Source.Camera)
videoTiles.push({
identity: user.identity,
const localPub = lp.getTrackPublication(Track.Source.Camera)
out.push({
identity: lp.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-camera",
track: localPub?.track,
attachable: localPub?.track,
source: Track.Source.Camera,
})
}
if (session.screenShareOn) {
const localPub = user.getTrackPublication(Track.Source.ScreenShare)
videoTiles.push({
identity: user.identity,
const localPub = lp.getTrackPublication(Track.Source.ScreenShare)
out.push({
identity: lp.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-screen",
track: localPub?.track,
attachable: localPub?.track,
source: Track.Source.ScreenShare,
})
}
@@ -103,70 +80,70 @@
for (const rp of room.remoteParticipants.values()) {
const camPub = rp.getTrackPublication(Track.Source.Camera)
if (camPub?.isSubscribed && camPub.track) {
videoTiles.push({
out.push({
identity: rp.identity,
isLocal: false,
trackSid: camPub.trackSid,
track: camPub.track,
attachable: camPub.track,
source: Track.Source.Camera,
})
}
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
if (screenPub?.isSubscribed && screenPub.track) {
videoTiles.push({
out.push({
identity: rp.identity,
isLocal: false,
trackSid: screenPub.trackSid,
track: screenPub.track,
attachable: screenPub.track,
source: Track.Source.ScreenShare,
})
}
}
return videoTiles
return out
})
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
const tileKey = (t: VideoTileData) => `${t.identity}\x1f${t.source}`
const tileKey = (t: Tile) => `${t.identity}\x1f${t.source}`
const primaryTile = $derived.by(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return undefined
return videoTiles.find(t => tileKey(t) === k)
return tiles.find(t => tileKey(t) === k)
})
const secondaryTiles = $derived.by(() => {
const p = primaryTile
if (p === undefined) return videoTiles
if (p === undefined) return tiles
const pk = tileKey(p)
return videoTiles.filter(t => tileKey(t) !== pk)
return tiles.filter(t => tileKey(t) !== pk)
})
const useSpotlightLayout = $derived(primaryTile !== undefined)
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
const useMultiGrid = $derived(!useSpotlightLayout && tiles.length > 2)
$effect(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return
if (!videoTiles.some(t => tileKey(t) === k)) {
if (!tiles.some(t => tileKey(t) === k)) {
videoPrimaryTileKey.set(undefined)
}
})
$effect(() => {
for (const t of videoTiles) {
for (const t of tiles) {
const pk = pubkeyFromLiveKitIdentity(t.identity)
if (pk) loadProfile(pk)
}
})
const labelFor = (identity: string, source: VideoTileData["source"]) => {
const labelFor = (identity: string, source: Tile["source"]) => {
const pk = pubkeyFromLiveKitIdentity(identity)
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
return source === Track.Source.ScreenShare ? `${name} · screen` : name
}
const showTileGrid = $derived(videoTiles.length > 0)
const showTileGrid = $derived(tiles.length > 0)
const spotlightHandlerFor = (key: string) => () => {
toggleVideoPrimaryTile(key)
@@ -174,16 +151,18 @@
const panelChrome = $derived(
cx(
mobile &&
"flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden bg-base-200 px-2 pt-4 md:hidden pb-[calc(3.5rem+var(--saib))]",
!mobile &&
"flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-base-200 px-2 pb-2 pt-4",
variant === "mobile" &&
"cb top-[calc(var(--sait)+6rem)] cw z-compose bg-[var(--video-call-panel-bg)] fixed inset-x-0 flex min-h-0 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2 pt-1 md:hidden",
variant === "desktop-split" &&
"cb ct cw-split-video z-compose bg-[var(--video-call-panel-bg)] fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
variant === "desktop-full" &&
"cb ct cw z-compose bg-[var(--video-call-panel-bg)] fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
className,
),
)
</script>
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
{#snippet videoTile(tile: Tile, layout: TileLayout)}
<div
class={cx(
"relative isolate overflow-hidden rounded-box shadow-sm",
@@ -192,9 +171,9 @@
layout === "strip" && "aspect-video w-44 shrink-0",
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100",
)}>
{#if tile.track}
<VideoCallTile
track={tile.track}
{#if tile.attachable}
<VideoCallVideo
track={tile.attachable}
muted={tile.isLocal}
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
class="pointer-events-none absolute inset-0" />
@@ -207,7 +186,7 @@
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
</span>
{#if videoTiles.length > 1}
{#if tiles.length > 1}
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
<Button
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
@@ -240,13 +219,13 @@
{:else if useMultiGrid}
<div
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
{#each videoTiles as tile (tileKey(tile))}
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{:else}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{#each videoTiles as tile (tileKey(tile))}
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
@@ -260,14 +239,14 @@
{/if}
{/snippet}
{#if showVideoContent}
{#if showPanel}
<div class={panelChrome}>
{#if mobile}
{#if variant === "mobile"}
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="min-h-0 flex-1 overflow-hidden">
{@render videoPanelBody()}
</div>
<div class="shrink-0 pb-2">
<div class="shrink-0">
<VoiceWidget />
</div>
</div>
@@ -11,21 +11,21 @@
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
let videoElement = $state<HTMLVideoElement | undefined>()
let el = $state<HTMLVideoElement | undefined>()
$effect(() => {
const element = videoElement
const activeTrack = track
if (!element) return
activeTrack.attach(element)
const v = el
const t = track
if (!v) return
t.attach(v)
return () => {
activeTrack.detach(element)
t.detach(v)
}
})
</script>
<video
bind:this={videoElement}
bind:this={el}
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
playsinline
{muted}></video>
@@ -7,8 +7,13 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {currentVoiceSession, type VoiceSession} from "@app/call/stores"
import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice"
import {
currentVoiceSession,
DeviceKind,
supportsAudioOutputSelection,
switchVoiceActiveDevice,
type VoiceSession,
} from "@app/voice"
import {popModal} from "@app/util/modal"
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
@@ -41,9 +46,16 @@
}
$effect(() => {
loadDevices()
navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices)
return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices)
void loadDevices()
const md = navigator.mediaDevices
if (!md?.addEventListener) return
const onDeviceChange = () => {
void loadDevices()
}
md.addEventListener("devicechange", onDeviceChange)
return () => {
md.removeEventListener("devicechange", onDeviceChange)
}
})
$effect(() => {
+5 -4
View File
@@ -12,13 +12,14 @@
import {makeRoomId} from "@app/core/state"
import {
VoiceState,
deriveVoiceParticipants,
cancelJoinVoiceRoom,
currentVoiceRoom,
voiceState,
isParticipantSpeaking,
participantKey,
voiceState,
type VoiceParticipant,
} from "@app/call/stores"
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
} from "@app/voice"
interface Props {
url: string
@@ -63,7 +64,7 @@
{replaceState}
{notification}
onclick={handleClick}
class={cx("items-start!", isActive && "bg-base-100! text-base-content!")}>
class={cx("!items-start", isActive && "!bg-base-100 !text-base-content")}>
<div class="flex w-full min-w-0 flex-col gap-2">
<div class="flex gap-2 items-center">
{#if isJoining}

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