diff --git a/.env b/.env index fde2a546..517b5fd2 100644 --- a/.env +++ b/.env @@ -19,5 +19,6 @@ 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= diff --git a/.fdignore b/.fdignore index 8d2a3cfa..de9ba2b3 100644 --- a/.fdignore +++ b/.fdignore @@ -1,4 +1,5 @@ src/assets +.claude target build .idea @@ -13,4 +14,4 @@ ios/App/Pods/ android/capacitor-cordova-android-plugins android/app/src/androidTest android/app/src/test - +node_modules diff --git a/.gitignore b/.gitignore index 99329539..5839829b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ node_modules/ .pnpm-store/ build/ .svelte-kit/ +.next/ # Rust/Tauri *target/ @@ -69,7 +70,9 @@ GoogleService-Info.plist .roo .idea/ .vscode/ +.claude/ # OS generated .DS_Store Thumbs.db +package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md index df3f0630..c70d3452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # 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 diff --git a/android/app/build.gradle b/android/app/build.gradle index 76757b5b..25411f58 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -8,8 +8,8 @@ android { applicationId "social.flotilla" minSdk rootProject.ext.minSdkVersion targetSdk rootProject.ext.targetSdkVersion - versionCode 44 - versionName "1.7.2" + versionCode 46 + versionName "1.7.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index da6f2661..f59918b1 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -12,10 +12,12 @@ 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') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 243166be..bafccd40 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -44,4 +44,7 @@ + + + diff --git a/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackPlugin.kt b/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackPlugin.kt index dff32c52..d394c984 100644 --- a/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackPlugin.kt +++ b/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackPlugin.kt @@ -7,6 +7,7 @@ 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 @@ -76,6 +77,7 @@ class AndroidPushFallbackPlugin : Plugin() { val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java) .setConstraints(constraints) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() workManager.enqueueUniquePeriodicWork( diff --git a/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackWorker.kt b/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackWorker.kt index 0466ccc3..1aee3eff 100644 --- a/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackWorker.kt +++ b/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackWorker.kt @@ -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 = 20L + private const val SOCKET_TIMEOUT_SECONDS = 30L private const val REJECTED = "__REJECTED__" private const val KIND_RELAY_AUTH = 22242 private const val KIND_NIP46_RPC = 24133 @@ -72,6 +72,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo } override fun doWork(): Result { + Log.i(TAG, "doWork() started") + if (isAppInForeground()) { return Result.success() } @@ -88,7 +90,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo val activeSince = state.optLong("activeSince", 0L) val seen = mutableSetOf() - var latestPair: Pair? = null + val newEvents = mutableListOf>() for (sub in subscriptions) { val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key @@ -102,23 +104,19 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo for (event in result.events) { val id = event.optString("id", "") if (id.isNotEmpty() && seen.add(id)) { - val createdAt = event.optLong("created_at", 0L) - if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) { - latestPair = Pair(sub.relay, event) - } + newEvents.add(Pair(sub.relay, event)) } } } - if (latestPair != null) { - val (relay, event) = latestPair!! + for ((relay, event) in newEvents) { postNotification(relay, event) } return Result.success() } catch (e: Exception) { Log.e(TAG, "Worker failed", e) - return Result.success() + return Result.retry() } finally { pool.closeAll() client.dispatcher.executorService.shutdown() @@ -214,7 +212,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo .setContentIntent(pendingIntent) .build() - NotificationManagerCompat.from(context).notify(1, notification) + val notificationId = id.hashCode().let { if (it == 0) 1 else it } + NotificationManagerCompat.from(context).notify(notificationId, notification) } private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean { diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 317412b2..175da8a3 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -11,6 +11,9 @@ 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') @@ -23,6 +26,9 @@ 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') diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index ff51fb7c..e4078f8b 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -358,14 +358,14 @@ CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 37; 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.2; + MARKETING_VERSION = 1.7.4; 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 = 35; + CURRENT_PROJECT_VERSION = 37; 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.2; + MARKETING_VERSION = 1.7.4; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 6d05083f..a4078e58 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -24,8 +24,10 @@ LSRequiresIPhoneOS + NSCameraUsageDescription + Flotilla uses the camera when you enable it in a voice room. NSMicrophoneUsageDescription - Flotilla uses the microphone for voice chat in rooms. + Flotilla uses the microphone when you enable it in a voice room. UIBackgroundModes remote-notification diff --git a/ios/App/Podfile b/ios/App/Podfile index 549e20cf..4ba07e33 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -14,10 +14,12 @@ 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 diff --git a/postcss.config.js b/postcss.config.js index 2e7af2b7..fb05b569 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,6 +1,5 @@ export default { plugins: { - tailwindcss: {}, - autoprefixer: {}, + "@tailwindcss/postcss": {}, }, } diff --git a/src/app.css b/src/app.css index ae198f34..80c26d51 100644 --- a/src/app.css +++ b/src/app.css @@ -1,46 +1,6 @@ -@import "@welshman/editor/index.css"; +@import "tailwindcss"; -@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"); -} +@config "../tailwind.config.js"; /* root */ @@ -52,98 +12,244 @@ --sair: var(--safe-area-inset-right, env(safe-area-inset-right)); } -[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 pt-sai { + padding-top: var(--sait); } -.mobile [data-tip]::before { - display: none !important; +@utility pr-sai { + padding-right: var(--sair); } -/* safe area insets */ +@utility pb-sai { + padding-bottom: var(--saib); +} -@layer components { - .pt-sai { - padding-top: var(--sait); +@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"); } - .pr-sai { - padding-right: var(--sair); + @font-face { + font-family: "Lato"; + font-style: normal; + font-weight: 400; + src: + local(""), + url("/fonts/Lato-Regular.ttf") format("truetype"); } - .pb-sai { - padding-bottom: var(--saib); + @font-face { + font-family: "Lato"; + font-style: bold; + font-weight: 600; + src: + local(""), + url("/fonts/Lato-Bold.ttf") format("truetype"); } - .pl-sai { - padding-left: var(--sail); + @font-face { + font-family: "Lato"; + font-style: italic; + font-weight: 400; + src: + local(""), + url("/fonts/Italic.ttf") format("truetype"); } - .px-sai { - @apply pl-sai pr-sai; + /* 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)); } - .py-sai { - @apply pt-sai pb-sai; + [data-theme] { + @apply bg-base-300; } - .p-sai { - @apply py-sai px-sai; + .mobile [data-tip]::before { + display: none !important; } - .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); - } + /* safe area insets */ } /* utilities */ @@ -165,110 +271,18 @@ @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 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; + @apply text-base-content p-2 sm:p-4; } [data-tip]::before { - @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; + @apply overflow-hidden text-ellipsis; } .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, @@ -278,21 +292,21 @@ } .tiptap { - --tiptap-object-bg: var(--neutral); - --tiptap-object-fg: var(--neutral-content); - --tiptap-active-bg: var(--primary); - --tiptap-active-fg: var(--primary-content); + --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-suggestions { - --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-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-suggestions__item { - @apply border-l-2 border-solid border-base-100; + @apply border-base-100 border-l-2 border-solid; } .tiptap-suggestions__selected { @@ -312,13 +326,13 @@ } .note-editor .tiptap { - --tiptap-object-bg: var(--base-200); - @apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6; + --tiptap-object-bg: var(--color-base-200); + @apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6; } .input-editor .tiptap { - --tiptap-object-bg: var(--base-200); - @apply input input-bordered h-auto p-[.65rem]; + --tiptap-object-bg: var(--color-base-200); + @apply input h-auto p-[.65rem]; } /* link-content, based on tiptap */ @@ -330,8 +344,8 @@ white-space: nowrap; border-radius: 3px; padding: 0 0.25rem; - background-color: var(--base-100); - color: var(--base-content); + background-color: var(--color-base-100); + color: var(--color-base-content); } /* content rendered by welshman/content */ @@ -347,23 +361,31 @@ /* date input */ .picker { - --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-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-time-field { - @apply input input-bordered rounded-lg px-0; + @apply input 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; } @@ -371,15 +393,15 @@ /* emoji picker */ emoji-picker { - --background: var(--base-100); - --border-color: var(--base-100); + --background: var(--color-base-100); + --border-color: var(--color-base-100); --border-radius: var(--rounded-box); - --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); + --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); } /* progress */ @@ -390,28 +412,12 @@ progress[value]::-webkit-progress-value { /* content width for fixed elements */ -.cw { - @apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))]; -} - -.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)]; +.left-content { + @apply md:left-[calc(18.5rem+var(--sail))]; } /* Keyboard open state adjustments */ -body.keyboard-open .cb { - @apply bottom-sai; -} - body.keyboard-open .hide-on-keyboard { display: none; } @@ -419,23 +425,13 @@ body.keyboard-open .hide-on-keyboard { /* chat view */ .chat__compose { - @apply cb cw fixed z-compose; + @apply z-compose relative mb-14 shrink-0 md:mb-0; } -.chat__compose-zone { - @apply cb cw fixed z-compose; -} - -.chat__compose-zone .chat__compose-inner { +.chat__compose .chat__compose-inner { @apply min-w-0; } .chat__scroll-down { - @apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16; -} - -/* content visibility */ - -.cv { - content-visibility: auto; + @apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16; } diff --git a/src/app/call/stores.ts b/src/app/call/stores.ts new file mode 100644 index 00000000..abe4084e --- /dev/null +++ b/src/app/call/stores.ts @@ -0,0 +1,57 @@ +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(undefined) + +export const voiceState = writable(VoiceState.Disconnected) + +export const currentVoiceRoom = writable(undefined) + +export const participantPubkeyMap = writable>(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([]) + +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)) + }, +) diff --git a/src/app/call/video.ts b/src/app/call/video.ts new file mode 100644 index 00000000..eccd4397 --- /dev/null +++ b/src/app/call/video.ts @@ -0,0 +1,99 @@ +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.Split) + +export const resetVideoCallLayout = () => { + videoCallViewportSync.previousLayout = undefined + videoCallLayout.set(VideoCallLayout.Chat) +} + +export const videoPrimaryTileKey = writable(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", + }) + } +} diff --git a/src/app/voice.ts b/src/app/call/voice.ts similarity index 82% rename from src/app/voice.ts rename to src/app/call/voice.ts index 7fb1f241..07ab0648 100644 --- a/src/app/voice.ts +++ b/src/app/call/voice.ts @@ -4,21 +4,35 @@ */ import { DisconnectReason, + LocalParticipant, + LocalTrackPublication, Room as LiveKitRoom, RoomEvent, Track, supportsAudioOutputSelection, type AudioCaptureOptions, - type LocalParticipant, } from "livekit-client" -import {derived, get, writable} from "svelte/store" +import {derived, get} from "svelte/store" import {map, removeUndefined, uniqBy} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {signer} from "@welshman/app" import {getLivekitEndpoint} from "$lib/livekit" import {AbortError, whenAborted, whenTimeout} from "$lib/util" -import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state" +import { + currentVoiceRoom, + currentVoiceSession, + participantFromLiveKitIdentity, + participantKey, + participantPubkeyMap, + pubkeyFromLiveKitIdentity, + speakingParticipants, + VoiceState, + type VoiceParticipant, + voiceState, +} from "@app/call/stores" +import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video" +import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state" import {pushToast} from "@app/util/toast" export const LIVEKIT_PARTICIPANTS = 39004 @@ -27,30 +41,12 @@ export {checkRelayHasLivekit} from "$lib/livekit" export {supportsAudioOutputSelection} -export type VoiceSession = { - url: string - h: string - room: LiveKitRoom - muted: 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(undefined) - const LIVEKIT_DEFAULT_DEVICE_ID = "default" export enum DeviceKind { AudioInput = "audioinput", AudioOutput = "audiooutput", + VideoInput = "videoinput", } export const switchVoiceActiveDevice = async ( @@ -71,17 +67,14 @@ export const switchVoiceActiveDevice = async ( case DeviceKind.AudioOutput: label = "speaker" break + case DeviceKind.VideoInput: + label = "camera" + break } pushToast({theme: "error", message: `Error changing ${label}`}) } } -export const voiceState = writable(VoiceState.Disconnected) - -export const currentVoiceRoom = writable(undefined) - -export const participantPubkeyMap = writable>(new Map()) - const addParticipant = (identity: string) => { participantPubkeyMap.update(m => { const next = new Map(m) @@ -98,24 +91,6 @@ const deleteParticipant = (identity: string) => { }) } -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([]) - -export const isParticipantSpeaking = derived( - speakingParticipants, - $participants => (p: VoiceParticipant) => - $participants.some(sp => participantKey(sp) === participantKey(p)), -) - const fetchLivekitToken = async ( url: string, groupId: string, @@ -197,7 +172,9 @@ const setUpMicrophone = async ( } const onRoomDisconnected = (reason?: DisconnectReason) => { + videoPrimaryTileKey.set(undefined) currentVoiceSession.set(undefined) + resetVideoCallLayout() if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { voiceState.set(VoiceState.Disconnected) const message = @@ -216,11 +193,16 @@ const onTrackSubscribed = (track: Track) => { element.style.display = "none" document.body.appendChild(element) element.play().catch(() => {}) + } else if (track.kind === Track.Kind.Video) { + triggerVideoFeedCount() } } const onTrackUnsubscribed = (track: Track) => { track.detach().forEach(el => el.remove()) + if (track.kind === Track.Kind.Video) { + triggerVideoFeedCount() + } } const onActiveSpeakersChanged = (participants: {identity: string}[]) => { @@ -241,6 +223,17 @@ const onParticipantDisconnected = (participant: {identity: string}) => { deleteParticipant(participant.identity) } +const onLocalTrackUnpublished = ( + publication: LocalTrackPublication, + participant: LocalParticipant, +) => { + if (publication.source !== Track.Source.ScreenShare) return + const session = get(currentVoiceSession) + if (!session || participant.identity !== session.room.localParticipant.identity) return + if (!session.screenShareOn) return + currentVoiceSession.set({...session, screenShareOn: false}) +} + let joinAbortController: AbortController | undefined export const cancelJoinVoiceRoom = () => { @@ -278,12 +271,13 @@ export const joinVoiceRoom = async ( liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed) liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) + liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished) liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) try { await Promise.race([ liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}), - whenTimeout(5_000, { + whenTimeout(15_000, { message: "Connection timed out. Please check your network and try again.", }), whenAborted(signal), @@ -301,7 +295,14 @@ export const joinVoiceRoom = async ( const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant) - currentVoiceSession.set({url, h, room: liveKitRoom, muted}) + currentVoiceSession.set({ + url, + h, + room: liveKitRoom, + muted, + cameraOn: false, + screenShareOn: false, + }) voiceState.set(VoiceState.Connected) playJoinSound() } catch (e) { @@ -320,8 +321,26 @@ export const leaveVoiceRoom = async () => { const audio = new Audio("/leave-voice-room.mp3") audio.play().catch(() => {}) + if (session.cameraOn) { + try { + await session.room.localParticipant.setCameraEnabled(false) + } catch { + pushToast({theme: "error", message: "Error turning off camera."}) + } + } + + if (session.screenShareOn) { + try { + await session.room.localParticipant.setScreenShareEnabled(false) + } catch { + pushToast({theme: "error", message: "Error turning off screen sharing."}) + } + } + voiceState.set(VoiceState.Disconnected) + videoPrimaryTileKey.set(undefined) currentVoiceSession.set(undefined) + resetVideoCallLayout() session.room.disconnect() speakingParticipants.set([]) participantPubkeyMap.set(new Map()) diff --git a/src/app/components/CalendarEventActions.svelte b/src/app/components/CalendarEventActions.svelte index a328a063..864fe35f 100644 --- a/src/app/components/CalendarEventActions.svelte +++ b/src/app/components/CalendarEventActions.svelte @@ -38,7 +38,7 @@ publishReaction({...template, event, relays: [url], protect: await shouldProtect}) -
+
{#if h && showRoom} Posted in # diff --git a/src/app/components/CalendarEventCreate.svelte b/src/app/components/CalendarEventCreate.svelte index 25dc189a..2c9a325b 100644 --- a/src/app/components/CalendarEventCreate.svelte +++ b/src/app/components/CalendarEventCreate.svelte @@ -7,12 +7,13 @@ type Props = { url: string h?: string + shareToChat?: boolean } - const {url, h}: Props = $props() + const {url, h, shareToChat = false}: Props = $props() - + {#snippet header()} Create an Event diff --git a/src/app/components/CalendarEventForm.svelte b/src/app/components/CalendarEventForm.svelte index 6ccf1d48..1f57c8c0 100644 --- a/src/app/components/CalendarEventForm.svelte +++ b/src/app/components/CalendarEventForm.svelte @@ -3,7 +3,7 @@ import {writable} from "svelte/store" import {randomId, HOUR} from "@welshman/lib" import {makeEvent, EVENT_TIME} from "@welshman/util" - import {publishThunk} from "@welshman/app" + import {publishThunk, waitForThunkError} from "@welshman/app" import {preventDefault} from "@lib/html" import {daysBetween} from "@lib/util" import GallerySend from "@assets/icons/gallery-send.svg?dataurl" @@ -20,24 +20,34 @@ 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} from "@app/core/commands" + import {canEnforceNip70, publishRoomQuote} from "@app/core/commands" + + type Values = { + d: string + title: string + content: string | object + location: string + start?: number + end?: number + } type Props = { url: string h?: string + shareToChat?: boolean header: Snippet - initialValues?: { - d: string - title: string - content: string - location: string - start: number - end: number - } + initialValues?: Values } - const {url, h, header, initialValues}: Props = $props() + let {url, h, shareToChat = false, header, initialValues}: Props = $props() + + const draftKey = new DraftKey(`calendar:${url}:${h ?? ""}`) + + if (!initialValues) { + initialValues = draftKey.get() + } const shouldProtect = canEnforceNip70(url) @@ -48,7 +58,7 @@ const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run()) const submit = async () => { - if ($uploading) return + if ($uploading || loading) return if (!title) { return pushToast({ @@ -74,38 +84,68 @@ const ed = await editor const content = ed.getText({blockSeparator: "\n"}).trim() const tags = [ - ["d", initialValues?.d || randomId()], + ["d", d], ["title", title], - ["location", location || ""], + ["location", location], ["start", start.toString()], ["end", end.toString()], ...daysBetween(start, end).map(D => ["D", D.toString()]), ...ed.storage.nostr.getEditorTags(), ] - if (await shouldProtect) { - tags.push(PROTECTED) + 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 (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() } - const content = initialValues?.content || "" - const editor = makeEditor({url, submit, uploading, content}) + let loading = $state(false) - let title = $state(initialValues?.title || "") - let location = $state(initialValues?.location || "") + const d = $state(initialValues?.d ?? randomId()) + 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 = Boolean(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}) + }) $effect(() => { if (!endDirty && start) { @@ -136,10 +176,14 @@ {#snippet input()}
-
+
- - diff --git a/src/app/components/CalendarEventHeader.svelte b/src/app/components/CalendarEventHeader.svelte index 857b2e0e..58a84d8a 100644 --- a/src/app/components/CalendarEventHeader.svelte +++ b/src/app/components/CalendarEventHeader.svelte @@ -19,7 +19,7 @@ const end = $derived(parseInt(meta.end)) -
+

{meta.title || meta.name}

{#if !isNaN(start) && !isNaN(end)} {@const startDateDisplay = formatTimestampAsDate(start)} diff --git a/src/app/components/CalendarEventMeta.svelte b/src/app/components/CalendarEventMeta.svelte index f8357ed0..2669d123 100644 --- a/src/app/components/CalendarEventMeta.svelte +++ b/src/app/components/CalendarEventMeta.svelte @@ -23,7 +23,7 @@ {#if meta.location} - {meta.location} + {meta.location} {/if}
diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index 26e84db8..be5f02a9 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -55,6 +55,7 @@ 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" @@ -66,6 +67,7 @@ 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))) @@ -196,8 +198,6 @@ 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,20 +233,6 @@ 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(() => { @@ -293,8 +279,7 @@
- -
+ {#if missingRelayLists.length > 0}
@@ -335,9 +320,10 @@ {@render info?.()}

+
-
+
{#if parent} @@ -352,7 +338,8 @@ {onSubmit} {onEscape} {onEditPrevious} - content={eventToEdit?.content} + initialValues={eventToEdit} + draftKey={eventToEdit ? undefined : draftKey} disabled={Boolean(missingRelayLists.length)} /> {/key}
diff --git a/src/app/components/ChatCompose.svelte b/src/app/components/ChatCompose.svelte index 68b62447..f5bba8a4 100644 --- a/src/app/components/ChatCompose.svelte +++ b/src/app/components/ChatCompose.svelte @@ -10,23 +10,40 @@ 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 onEscape?: () => void onEditPrevious?: () => void onSubmit: (event: EventContent) => void + initialValues?: Values } - const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props() + let { + initialValues, + disabled = false, + draftKey, + onEscape, + onEditPrevious, + onSubmit, + }: Props = $props() + + if (!initialValues) { + initialValues = draftKey?.get() + } const autofocus = !isMobile && !disabled const uploading = writable(false) const editorClass = $derived( - cx("chat-editor flex-grow overflow-hidden", { + cx("chat-editor grow overflow-hidden", { "pointer-events-none opacity-50": disabled, }), ) @@ -59,18 +76,29 @@ 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) @@ -95,7 +123,7 @@ {/if}
- +
+
  • + +
  • diff --git a/src/app/components/Content.svelte b/src/app/components/Content.svelte index 963177a6..e252b0bb 100644 --- a/src/app/components/Content.svelte +++ b/src/app/components/Content.svelte @@ -150,7 +150,7 @@
    {:else}
    {#each shortContent as parsed, i} {#if isNewline(parsed) && !isBlock(i - 1)} diff --git a/src/app/components/ContentLinkBlock.svelte b/src/app/components/ContentLinkBlock.svelte index ec981015..fec50b30 100644 --- a/src/app/components/ContentLinkBlock.svelte +++ b/src/app/components/ContentLinkBlock.svelte @@ -1,27 +1,44 @@ - -
    - {#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)} - - {:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)} - - {:else} - {#await loadPreview()} -
    - -
    - {:then preview} -
    - {#if preview.image && !hideImage} - - {/if} -
    - {preview.title || displayUrl(url)} -

    {ellipsize(preview.description, 140)}

    -
    -
    - {:catch} -

    - Unable to load a preview for {url} -

    - {/await} - {/if} +{#if isRoomOrRelay} +
    +
    - +{:else} + +
    + {#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)} + + {:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)} + + {:else} + {#await loadPreview()} +
    + +
    + {:then preview} +
    + {#if preview.image && !hideImage} + + {/if} +
    + {preview.title || displayUrl(url)} +

    {ellipsize(preview.description, 140)}

    +
    +
    + {:catch} +

    + Unable to load a preview for {url} +

    + {/await} + {/if} +
    + +{/if} diff --git a/src/app/components/ContentLinkInline.svelte b/src/app/components/ContentLinkInline.svelte index 949c5910..fec1055c 100644 --- a/src/app/components/ContentLinkInline.svelte +++ b/src/app/components/ContentLinkInline.svelte @@ -1,25 +1,18 @@ @@ -34,8 +27,5 @@ {displayUrl(url)} {:else} - - - {displayUrl(url)} - + {/if} diff --git a/src/app/components/ContentLinkUrl.svelte b/src/app/components/ContentLinkUrl.svelte new file mode 100644 index 00000000..38a25e12 --- /dev/null +++ b/src/app/components/ContentLinkUrl.svelte @@ -0,0 +1,59 @@ + + + + {#if roomReference} + ~{displayRelayUrl(roomReference.url)} / + {displayRoom(roomReference.url, roomReference.h)} + {:else if relayReference} + {displayRelayUrl(relayReference)} + {:else} + + {displayUrl(url)} + {/if} + diff --git a/src/app/components/ContentMinimal.svelte b/src/app/components/ContentMinimal.svelte index d90667fb..e2e22bfa 100644 --- a/src/app/components/ContentMinimal.svelte +++ b/src/app/components/ContentMinimal.svelte @@ -101,7 +101,7 @@

    {:else} -
    +
    {#each shortContent as parsed, i} {#if isNewline(parsed)} diff --git a/src/app/components/ContentQuote.svelte b/src/app/components/ContentQuote.svelte index 2d2e8853..f36993f8 100644 --- a/src/app/components/ContentQuote.svelte +++ b/src/app/components/ContentQuote.svelte @@ -45,11 +45,11 @@ {#if $quote.kind === MESSAGE}
    + style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
    {:else} - + {/if} diff --git a/src/app/components/EventActions.svelte b/src/app/components/EventActions.svelte index b105df1d..c4e27af6 100644 --- a/src/app/components/EventActions.svelte +++ b/src/app/components/EventActions.svelte @@ -42,7 +42,7 @@ let popover: Instance | undefined = $state() - - +
    diff --git a/src/app/components/EventInfo.svelte b/src/app/components/EventInfo.svelte index 59773f8b..3acbe579 100644 --- a/src/app/components/EventInfo.svelte +++ b/src/app/components/EventInfo.svelte @@ -101,7 +101,7 @@ {/if}
    {json}
    -

    +

    @@ -109,6 +109,6 @@

    - + diff --git a/src/app/components/EventReply.svelte b/src/app/components/EventReply.svelte index 20a9826a..d7377826 100644 --- a/src/app/components/EventReply.svelte +++ b/src/app/components/EventReply.svelte @@ -10,13 +10,19 @@ 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(`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()) @@ -38,13 +44,23 @@ }) } + 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(() => { @@ -52,7 +68,7 @@ }) const observer = new ResizeObserver(() => { - spacer!.style.minHeight = `${form!.offsetHeight}px` + spacer!.style.minHeight = `${form!.offsetHeight + 60}px` }) observer.observe(form!) @@ -64,11 +80,15 @@
    -
    +
    -
    - +
    +
    - + diff --git a/src/app/components/InfoZapperError.svelte b/src/app/components/InfoZapperError.svelte index 18625610..cc7468e0 100644 --- a/src/app/components/InfoZapperError.svelte +++ b/src/app/components/InfoZapperError.svelte @@ -23,7 +23,7 @@ Unable to Zap

    - Zapping isn't possible right now because + Zapping isn't possible right now because {#if $zapper} their zap receiver isn't correctly set up. {:else} diff --git a/src/app/components/InputProfilePicture.svelte b/src/app/components/InputProfilePicture.svelte index 26252c5a..339300ed 100644 --- a/src/app/components/InputProfilePicture.svelte +++ b/src/app/components/InputProfilePicture.svelte @@ -97,10 +97,10 @@ tabindex="-1" onmousedown={stopPropagation(onClear)} ontouchstart={stopPropagation(onClear)}> - + {:else} - + {/if}

    {#if !url} diff --git a/src/app/components/LogInBunker.svelte b/src/app/components/LogInBunker.svelte index d72d4807..c5fc17de 100644 --- a/src/app/components/LogInBunker.svelte +++ b/src/app/components/LogInBunker.svelte @@ -1,4 +1,5 @@ -
    +
    {#if muted}
    diff --git a/src/app/components/NoteContent.svelte b/src/app/components/NoteContent.svelte index d14c6c47..dd9126bf 100644 --- a/src/app/components/NoteContent.svelte +++ b/src/app/components/NoteContent.svelte @@ -1,10 +1,11 @@
    -
    +

    {meta.title || meta.name}

    {#if !isNaN(start) && !isNaN(end)} {@const startDateDisplay = formatTimestampAsDate(start)} diff --git a/src/app/components/NoteContentMinimalPoll.svelte b/src/app/components/NoteContentMinimalPoll.svelte new file mode 100644 index 00000000..25b5a622 --- /dev/null +++ b/src/app/components/NoteContentMinimalPoll.svelte @@ -0,0 +1,19 @@ + + +
    + + {$results.voters} voter{$results.voters === 1 ? "" : "s"} +
    diff --git a/src/app/components/NoteContentPoll.svelte b/src/app/components/NoteContentPoll.svelte new file mode 100644 index 00000000..5c4904f6 --- /dev/null +++ b/src/app/components/NoteContentPoll.svelte @@ -0,0 +1,29 @@ + + +
    + + + {#if props.url} + + {/if} +
    diff --git a/src/app/components/PollCreate.svelte b/src/app/components/PollCreate.svelte new file mode 100644 index 00000000..576a7034 --- /dev/null +++ b/src/app/components/PollCreate.svelte @@ -0,0 +1,280 @@ + + + + + + Create a Poll + Ask a question and collect votes right in the feed. + +
    + + {#snippet label()} +

    Question*

    + {/snippet} + {#snippet input()} + + {/snippet} +
    + + + {#snippet label()} +

    Options*

    + {/snippet} + {#snippet input()} +
    + {#each options as option, index (option.id)} +
    onDragStart(e, option.id)} + ondragover={e => onDragOver(e, option.id)} + ondrop={e => onDrop(e, option.id)} + ondragend={onDragEnd}> +
    + +
    + + +
    + {/each} + +
    + {/snippet} +
    + +
    + + {#snippet label()} + Poll type + {/snippet} + {#snippet input()} + + {/snippet} + + + {#snippet label()} + Ends at + {/snippet} + {#snippet input()} + + {/snippet} + +
    +
    +
    + + + + +
    diff --git a/src/app/components/PollItem.svelte b/src/app/components/PollItem.svelte new file mode 100644 index 00000000..1f96e96a --- /dev/null +++ b/src/app/components/PollItem.svelte @@ -0,0 +1,34 @@ + + + + +
    + + Posted by + {#if h} + in + {/if} + + +
    + diff --git a/src/app/components/PollOption.svelte b/src/app/components/PollOption.svelte new file mode 100644 index 00000000..b6146943 --- /dev/null +++ b/src/app/components/PollOption.svelte @@ -0,0 +1,70 @@ + + +
    +
    + + {votes} vote{votes === 1 ? "" : "s"} +
    + +
    diff --git a/src/app/components/PollVotes.svelte b/src/app/components/PollVotes.svelte new file mode 100644 index 00000000..ea7b3462 --- /dev/null +++ b/src/app/components/PollVotes.svelte @@ -0,0 +1,127 @@ + + +
    + {#each options as option (option.id)} + + {/each} +
    +
    + {pollType === "multiplechoice" ? "Multiple choice" : "Single choice"} + {#if endsAt} + {#if closed} + • Ended {formatTimestampRelative(endsAt)} + {:else} + • Ends {formatTimestampRelative(endsAt)} + {/if} + {/if} +
    +
    {results.voters} vote{results.voters === 1 ? "" : "s"}
    +
    +
    diff --git a/src/app/components/PrimaryNav.svelte b/src/app/components/PrimaryNav.svelte index c17ad236..96208aee 100644 --- a/src/app/components/PrimaryNav.svelte +++ b/src/app/components/PrimaryNav.svelte @@ -32,18 +32,14 @@