Compare commits

...

35 Commits

Author SHA1 Message Date
Jon Staab 61e93d4071 Update changelog, bump version 2026-04-16 11:40:24 -07:00
Jon Staab 1e4a4e43dc remove dead virtualization code 2026-04-16 11:39:11 -07:00
Jon Staab e1a7b051bd Use welshman kinds 2026-04-16 11:34:59 -07:00
sakshamjain 7a7af58f5c feat: add native share support for space invites 2026-04-16 10:16:12 -07:00
Jon Staab 016ae86d50 Stop sending duplicate requests per room 2026-04-16 10:03:01 -07:00
Jon Staab 2bff060a5e Add thumbnail url 2026-04-16 10:03:01 -07:00
userAdityaa 68231504d0 fix: modal close button stacking above emoji picker on mobile (#211)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 15:38:25 +00:00
deveshanim3 0658a8ee44 bug: fixed calender modal stacking issue (#209)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-16 14:55:37 +00:00
priyanshu_bharti 43fb3d35e6 Fix #202 slow-network invite timeout handling (#207)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-15 22:01:00 +00:00
Khushvendra 4cc1cc95ca Fix voice call cold-start timeout and preserve custom timeout message (#174) (#203)
Co-authored-by: 1amKhush <khushvendras99@gmail.com>
Co-committed-by: 1amKhush <khushvendras99@gmail.com>
2026-04-15 20:37:03 +00:00
Prat_09 964ef441ec Update relay description (#195) (#197)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-14 15:09:46 +00:00
priyanshu_bharti 796f37d320 Make space reordering discoverable with smoother drag animation (#171)
Co-authored-by: Priyanshu Bharti <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshu Bharti <bhartipriyanshustm@gmail.com>
2026-04-13 22:38:02 +00:00
Nayan Patidar b46fd94578 Use relay-provided member lists as source of truth (#191)
Co-authored-by: Nayan Patidar <nayan9617@noreply.coracle.social>
Co-committed-by: Nayan Patidar <nayan9617@noreply.coracle.social>
2026-04-13 21:12:49 +00:00
Jon Staab bdc8e75640 Fix search input width 2026-04-13 12:08:11 -07:00
Jon Staab ef08821796 remove VirtualItem 2026-04-13 10:35:26 -07:00
Jon Staab 9f386f6968 remove redundant room syncing logic 2026-04-11 10:20:50 -07:00
Khushvendra ec0b6a99e2 add room mentions and clickable room/relay refs 2026-04-11 10:13:23 -07:00
Khushvendra f6d9e52c6e fix: support native clipboard image paste on mobile (#181)
Co-authored-by: Khushvendra <khushvendras99@gmail.com>
Co-committed-by: Khushvendra <khushvendras99@gmail.com>
2026-04-11 16:38:06 +00:00
Jon Staab 90f86b833d Handle quotes in RoomItem. Fixes #188 2026-04-10 15:27:36 -07:00
userAdityaa 29bb33c26c publish kind 9 quote after room content creation for cross-client interoperability 2026-04-10 14:20:24 -07:00
Jon Staab c740bd21d4 Fix space page layout on Android by adding visible prop to SecondaryNav 2026-04-10 13:26:14 -07:00
Jon Staab 1d92709c76 perf: task-fix-list-virtualization changes 2026-04-10 12:44:01 -07:00
Jon Staab a42e1df1a7 Fix feed pagination logic 2026-04-10 12:40:28 -07:00
Jon Staab e33beee17d perf: task-fix-raf-derived-to-effect changes 2026-04-10 12:30:25 -07:00
Jon Staab b10ea04cb3 Fix Android push fallback: show all notifications, retry on failure 2026-04-10 12:23:32 -07:00
priyanshu_bharti e8c94177ca Support Aegis URL scheme for NIP-46 login (#161)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-10 19:04:34 +00:00
Jon Staab f1f2083c88 Remove unnecessary snapshots, format 2026-04-10 11:09:26 -07:00
Jon Staab f42889c3c2 Improve performance #182:
increase profile timer and chat search throttle delays
reduce GC pressure in derived stores
use requestIdleCallback for non-critical storage writes
batch repository update processing in feeds
2026-04-10 10:39:38 -07:00
Jon Staab a75e1f96eb Add .claude to gitignore 2026-04-10 10:14:01 -07:00
priyanshu_bharti 85c5293082 Raise message size limit in chat (#186)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-10 16:37:23 +00:00
Jon Staab 37efa6a62c Bump pomade 2026-04-10 09:24:22 -07:00
userAdityaa 1d5f91fb6c fix: realtime updates for room members and admins (#178)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-09 21:05:30 +00:00
userAdityaa ef18655776 make close button / backdrop work on direct invite link page (#177)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-09 20:03:08 +00:00
sakshamjain b786e858d9 correct inverted arrow icon in advanced section toggle (#180)
Co-authored-by: Saksham Jain <reach2saksham2004@gmail.com>
Co-committed-by: Saksham Jain <reach2saksham2004@gmail.com>
2026-04-09 19:57:15 +00:00
mplorentz f4ebc4e99e Video in calls (#135)
#135

This PR adds basic video functionality to our voice rooms. Again I followed the Discord UX for inspiration, so all video calls start as voice-only calls that gracefully upgrade (and downgrade) when someone turns on a video or starts screen sharing.

When a video feed is detected the Room page will change to display a grid of feeds. The grid logic is very basic, that's definitely an area to improve in the future. You can open the chat part of the room with a new button on the VoiceWidget - on the desktop layout this creates a split view with video on the left and chat on the right, but on mobile it switches to chat fullscreen. I also added a little pin icon you can use to focus on a single video feed (useful for screen sharing). There is a lot of tailwind I don't understand here, but it seems to work well enough.

I moved voice.ts into a new `call` folder and moved some of its stores into `call/stores.ts` which allowed me to keep most of the video logic in `call/video.ts`. It's not a perfect encapsulation as voice.ts does subscribe to some of the hooks for the livekit calls and passes some of the signals onto `video.ts`. This could probably be broken up better but for this PR I'd rather not focus on making it perfect if that's ok. Partly for the sake of time but also because I envision another PR that renames/reorganizes things and I think a larger UX evaluation is necessary and should include real user feedback. I'm not confident tha""t the Voice Room concept as a whole will stick going forward. Maybe all rooms in a livekit enabled server should be able to host a call (like a slack huddle), maybe users want to be able to schedule calls as events, or even have them start with an ad-hoc set of participants completely outside of a NIP-29 group, etc.

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: coracle/flotilla#135
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-08 17:10:20 +00:00
94 changed files with 2481 additions and 1004 deletions
+1 -1
View File
@@ -19,6 +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_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_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_THUMBNAIL_URL= VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
VITE_GLITCHTIP_API_KEY= VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN= GLITCHTIP_AUTH_TOKEN=
+2 -1
View File
@@ -1,4 +1,5 @@
src/assets src/assets
.claude
target target
build build
.idea .idea
@@ -13,4 +14,4 @@ ios/App/Pods/
android/capacitor-cordova-android-plugins android/capacitor-cordova-android-plugins
android/app/src/androidTest android/app/src/androidTest
android/app/src/test android/app/src/test
node_modules
+1
View File
@@ -70,6 +70,7 @@ GoogleService-Info.plist
.roo .roo
.idea/ .idea/
.vscode/ .vscode/
.claude/
# OS generated # OS generated
.DS_Store .DS_Store
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
# 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 # 1.7.2
* Fix race condition in nip 46 * Fix race condition in nip 46
+2 -2
View File
@@ -8,8 +8,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 44 versionCode 45
versionName "1.7.2" versionName "1.7.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2
View File
@@ -12,10 +12,12 @@ dependencies {
implementation project(':aparajita-capacitor-secure-storage') implementation project(':aparajita-capacitor-secure-storage')
implementation project(':capacitor-community-safe-area') implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-filesystem') implementation project(':capacitor-filesystem')
implementation project(':capacitor-keyboard') implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences') implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications') implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-android-dark-mode-support') implementation project(':capawesome-capacitor-android-dark-mode-support')
implementation project(':capawesome-capacitor-badge') implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin') implementation project(':nostr-signer-capacitor-plugin')
+3
View File
@@ -44,4 +44,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <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> </manifest>
@@ -7,6 +7,7 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
@@ -76,6 +77,7 @@ class AndroidPushFallbackPlugin : Plugin() {
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java) val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
.setConstraints(constraints) .setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build() .build()
workManager.enqueueUniquePeriodicWork( workManager.enqueueUniquePeriodicWork(
@@ -43,7 +43,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
private const val TAG = "PushFallback" private const val TAG = "PushFallback"
private const val CHANNEL_ID = "flotilla_fallback" private const val CHANNEL_ID = "flotilla_fallback"
private const val CURSOR_PREFIX = "androidPushFallback.cursor." 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 REJECTED = "__REJECTED__"
private const val KIND_RELAY_AUTH = 22242 private const val KIND_RELAY_AUTH = 22242
private const val KIND_NIP46_RPC = 24133 private const val KIND_NIP46_RPC = 24133
@@ -72,6 +72,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
} }
override fun doWork(): Result { override fun doWork(): Result {
Log.i(TAG, "doWork() started")
if (isAppInForeground()) { if (isAppInForeground()) {
return Result.success() return Result.success()
} }
@@ -88,7 +90,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
val activeSince = state.optLong("activeSince", 0L) val activeSince = state.optLong("activeSince", 0L)
val seen = mutableSetOf<String>() val seen = mutableSetOf<String>()
var latestPair: Pair<String, JSONObject>? = null val newEvents = mutableListOf<Pair<String, JSONObject>>()
for (sub in subscriptions) { for (sub in subscriptions) {
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
@@ -102,23 +104,19 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
for (event in result.events) { for (event in result.events) {
val id = event.optString("id", "") val id = event.optString("id", "")
if (id.isNotEmpty() && seen.add(id)) { if (id.isNotEmpty() && seen.add(id)) {
val createdAt = event.optLong("created_at", 0L) newEvents.add(Pair(sub.relay, event))
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
latestPair = Pair(sub.relay, event)
}
} }
} }
} }
if (latestPair != null) { for ((relay, event) in newEvents) {
val (relay, event) = latestPair!!
postNotification(relay, event) postNotification(relay, event)
} }
return Result.success() return Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Worker failed", e) Log.e(TAG, "Worker failed", e)
return Result.success() return Result.retry()
} finally { } finally {
pool.closeAll() pool.closeAll()
client.dispatcher.executorService.shutdown() client.dispatcher.executorService.shutdown()
@@ -214,7 +212,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.build() .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 { private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
+6
View File
@@ -11,6 +11,9 @@ project(':capacitor-community-safe-area').projectDir = new File('../node_modules
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.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' include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android') project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.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' include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android') project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.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' include ':capawesome-capacitor-android-dark-mode-support'
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android') project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.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_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.2; MARKETING_VERSION = 1.7.3;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -385,14 +385,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.2; MARKETING_VERSION = 1.7.3;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+3 -1
View File
@@ -24,8 +24,10 @@
<false/> <false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>Flotilla uses the camera when you enable it in a voice room.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Flotilla uses the microphone for voice chat in rooms.</string> <string>Flotilla uses the microphone when you enable it in a voice room.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>remote-notification</string> <string>remote-notification</string>
+2
View File
@@ -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 '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 '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 '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 '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 '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 '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 '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 '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' 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 end
+17 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.7.2", "version": "1.7.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -48,35 +48,38 @@
"@capacitor/android": "^8.0.1", "@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
"@capacitor/cli": "^8.0.1", "@capacitor/cli": "^8.0.1",
"@capacitor/clipboard": "^8.0.1",
"@capacitor/core": "^8.0.1", "@capacitor/core": "^8.0.1",
"@capacitor/filesystem": "^8.1.0", "@capacitor/filesystem": "^8.1.0",
"@capacitor/ios": "^8.0.1", "@capacitor/ios": "^8.0.1",
"@capacitor/keyboard": "^8.0.0", "@capacitor/keyboard": "^8.0.0",
"@capacitor/preferences": "^8.0.0", "@capacitor/preferences": "^8.0.0",
"@capacitor/push-notifications": "^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-android-dark-mode-support": "^8.0.0",
"@capawesome/capacitor-badge": "^8.0.0", "@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0", "@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2", "@getalby/sdk": "^5.1.2",
"@noble/curves": "^1.9.7", "@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.2", "@pomade/core": "^0.2.3",
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2", "@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8", "@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.8.12", "@welshman/app": "^0.8.13",
"@welshman/content": "^0.8.12", "@welshman/content": "^0.8.13",
"@welshman/editor": "^0.8.12", "@welshman/editor": "^0.8.13",
"@welshman/feeds": "^0.8.12", "@welshman/feeds": "^0.8.13",
"@welshman/lib": "^0.8.12", "@welshman/lib": "^0.8.13",
"@welshman/net": "^0.8.12", "@welshman/net": "^0.8.13",
"@welshman/router": "^0.8.12", "@welshman/router": "^0.8.13",
"@welshman/signer": "^0.8.12", "@welshman/signer": "^0.8.13",
"@welshman/store": "^0.8.12", "@welshman/store": "^0.8.13",
"@welshman/util": "^0.8.12", "@welshman/util": "^0.8.13",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"date-picker-svelte": "^2.17.0", "date-picker-svelte": "^2.17.0",
@@ -105,5 +108,6 @@
"overrides": { "overrides": {
"sharp": "0.35.0-rc.0" "sharp": "0.35.0-rc.0"
} }
} },
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
} }
+141 -114
View File
@@ -26,6 +26,9 @@ importers:
'@capacitor/cli': '@capacitor/cli':
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.0.1 version: 8.0.1
'@capacitor/clipboard':
specifier: ^8.0.1
version: 8.0.1(@capacitor/core@8.0.1)
'@capacitor/core': '@capacitor/core':
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.0.1 version: 8.0.1
@@ -44,6 +47,9 @@ importers:
'@capacitor/push-notifications': '@capacitor/push-notifications':
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0(@capacitor/core@8.0.1) version: 8.0.0(@capacitor/core@8.0.1)
'@capacitor/share':
specifier: ^8.0.1
version: 8.0.1(@capacitor/core@8.0.1)
'@capawesome/capacitor-android-dark-mode-support': '@capawesome/capacitor-android-dark-mode-support':
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0(@capacitor/core@8.0.1) version: 8.0.0(@capacitor/core@8.0.1)
@@ -60,8 +66,8 @@ importers:
specifier: ^1.9.7 specifier: ^1.9.7
version: 1.9.7 version: 1.9.7
'@pomade/core': '@pomade/core':
specifier: ^0.2.2 specifier: ^0.2.3
version: 0.2.2(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3)) version: 0.2.3(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.13(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
'@poppanator/sveltekit-svg': '@poppanator/sveltekit-svg':
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1(rollup@2.80.0)(svelte@5.48.0)(svgo@3.3.2)(vite@5.4.21(@types/node@25.0.10)(lightningcss@1.32.0)(terser@5.46.0)) version: 4.2.1(rollup@2.80.0)(svelte@5.48.0)(svgo@3.3.2)(vite@5.4.21(@types/node@25.0.10)(lightningcss@1.32.0)(terser@5.46.0))
@@ -71,6 +77,9 @@ importers:
'@tiptap/core': '@tiptap/core':
specifier: ^2.27.2 specifier: ^2.27.2
version: 2.27.2(@tiptap/pm@2.27.2) version: 2.27.2(@tiptap/pm@2.27.2)
'@tiptap/pm':
specifier: ^2.27.2
version: 2.27.2
'@types/qrcode': '@types/qrcode':
specifier: ^1.5.6 specifier: ^1.5.6
version: 1.5.6 version: 1.5.6
@@ -84,35 +93,35 @@ importers:
specifier: ^0.6.8 specifier: ^0.6.8
version: 0.6.8(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.48.0)(vite@5.4.21(@types/node@25.0.10)(lightningcss@1.32.0)(terser@5.46.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.10)(lightningcss@1.32.0)(terser@5.46.0)))(@vite-pwa/assets-generator@0.2.6)(vite-plugin-pwa@0.21.2(@vite-pwa/assets-generator@0.2.6)(vite@5.4.21(@types/node@25.0.10)(lightningcss@1.32.0)(terser@5.46.0))(workbox-build@7.3.0)(workbox-window@7.3.0)) version: 0.6.8(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.48.0)(vite@5.4.21(@types/node@25.0.10)(lightningcss@1.32.0)(terser@5.46.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.10)(lightningcss@1.32.0)(terser@5.46.0)))(@vite-pwa/assets-generator@0.2.6)(vite-plugin-pwa@0.21.2(@vite-pwa/assets-generator@0.2.6)(vite@5.4.21(@types/node@25.0.10)(lightningcss@1.32.0)(terser@5.46.0))(workbox-build@7.3.0)(workbox-window@7.3.0))
'@welshman/app': '@welshman/app':
specifier: ^0.8.12 specifier: ^0.8.13
version: 0.8.12(3074ef6691f94dc03952d8dbc98013a7) version: 0.8.13(ed9ee8a79a580bcb9fa9bb6eb1a69558)
'@welshman/content': '@welshman/content':
specifier: ^0.8.12 specifier: ^0.8.13
version: 0.8.12(nostr-tools@2.20.0(typescript@5.9.3)) version: 0.8.13(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/editor': '@welshman/editor':
specifier: ^0.8.12 specifier: ^0.8.13
version: 0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3)) version: 0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/feeds': '@welshman/feeds':
specifier: ^0.8.12 specifier: ^0.8.13
version: 0.8.12(d5b74f0c83250e052e0b96f7ff5804e8) version: 0.8.13(29451a19e278ea4a9cf66616f05d5557)
'@welshman/lib': '@welshman/lib':
specifier: ^0.8.12 specifier: ^0.8.13
version: 0.8.12 version: 0.8.13
'@welshman/net': '@welshman/net':
specifier: ^0.8.12 specifier: ^0.8.13
version: 0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3) version: 0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': '@welshman/router':
specifier: ^0.8.12 specifier: ^0.8.13
version: 0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))) version: 0.8.13(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': '@welshman/signer':
specifier: ^0.8.12 specifier: ^0.8.13
version: 0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)) version: 0.8.13(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/store': '@welshman/store':
specifier: ^0.8.12 specifier: ^0.8.13
version: 0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0) version: 0.8.13(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
'@welshman/util': '@welshman/util':
specifier: ^0.8.12 specifier: ^0.8.13
version: 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)) version: 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
compressorjs-next: compressorjs-next:
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2 version: 1.1.2
@@ -791,6 +800,11 @@ packages:
engines: {node: '>=22.0.0'} engines: {node: '>=22.0.0'}
hasBin: true hasBin: true
'@capacitor/clipboard@8.0.1':
resolution: {integrity: sha512-iOlbTi8MojKyLnYE+M27priXid7vHd0PlDwyHohPzkuQ8Rkp6q7ykwZmPEUD+OnU/Ink7Qw/pUOfKgraKmA6Eg==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/core@8.0.1': '@capacitor/core@8.0.1':
resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==} resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==}
@@ -827,6 +841,11 @@ packages:
peerDependencies: peerDependencies:
'@capacitor/core': '>=8.0.0' '@capacitor/core': '>=8.0.0'
'@capacitor/share@8.0.1':
resolution: {integrity: sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/synapse@1.0.4': '@capacitor/synapse@1.0.4':
resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==} resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==}
@@ -1427,9 +1446,9 @@ packages:
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@pomade/core@0.2.2': '@pomade/core@0.2.3':
resolution: {integrity: sha512-FoilLsO0gVjiKMW3LV63pmXU7x3gh8YVGVulyR6QJr4h47XrsBg8vPkZtKWr4+sH3sW31e2tNIPUb3ptiuhrMA==} resolution: {integrity: sha512-36+abWfMH1Mif9FjBO7xICCkGZE4IqQpy7Csxlauyt0bhYQ9GsB07LqK5Qm3GgEHNwF9rFXdSZawkM+E6BeIfg==}
version: 0.2.2 version: 0.2.3
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
peerDependencies: peerDependencies:
'@frostr/bifrost': ^1.0.7 '@frostr/bifrost': ^1.0.7
@@ -2131,83 +2150,83 @@ packages:
'@vite-pwa/assets-generator': '@vite-pwa/assets-generator':
optional: true optional: true
'@welshman/app@0.8.12': '@welshman/app@0.8.13':
resolution: {integrity: sha512-kRp+AVzn4i3FvZmdlyMknFUAb/5SnUz9A/cFKkDqWHsd+N3PbNcL2ZOlV9v5NI77GtsDF2ez6PEQfsZxWvkS/g==} resolution: {integrity: sha512-+mUMtt5ibotBk/susPFKXnb9jRjqvIQgWMF28poCIzse08V4kfVClJJlzepvgjqRn6Ma/takr6tNkL6eV4rlRQ==}
peerDependencies: peerDependencies:
'@pomade/core': ^0.2.1 '@pomade/core': ^0.2.1
'@welshman/feeds': 0.8.12 '@welshman/feeds': 0.8.13
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12 '@welshman/net': 0.8.13
'@welshman/router': 0.8.12 '@welshman/router': 0.8.13
'@welshman/signer': 0.8.12 '@welshman/signer': 0.8.13
'@welshman/store': 0.8.12 '@welshman/store': 0.8.13
'@welshman/util': 0.8.12 '@welshman/util': 0.8.13
svelte: ^4.0.0 || ^5.0.0 svelte: ^4.0.0 || ^5.0.0
'@welshman/content@0.8.12': '@welshman/content@0.8.13':
resolution: {integrity: sha512-hviVTXdyGf04Xq7mGo/82fq6lnbyuUYOGPkf8pJqPkfGh0f3i9nKof6gkzPvjZeEYSczneI2GpLIxkaZ3w1/tw==} resolution: {integrity: sha512-6ZDKCJ2GKczAULD7P7NZ5DmxFYKw6vfxJ1jpwbQj+0l7alr2IBh8kmaQ8wM1r6n0qOhfcNqeGaaREQxC4VnuHA==}
peerDependencies: peerDependencies:
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@welshman/editor@0.8.12': '@welshman/editor@0.8.13':
resolution: {integrity: sha512-CEXszH6pfM1kZHU9WnB6Z97YlxEOtgao6R3hhnBL/kRXy2tUTLpmFWyMONg2vu8Uzxtwz665eZhucLsju60U6w==} resolution: {integrity: sha512-kr4pSjQ/TZnlyIeGo0UNNAQrTGpp0yMRUFD/LwORVLnC8UGNLwGRmFwOz0WNtCxGxFGquTlX1AkNfViWdkfXHw==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/util': 0.8.12 '@welshman/util': 0.8.13
nostr-editor: ^1.1.1 nostr-editor: ^1.1.1
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@welshman/feeds@0.8.12': '@welshman/feeds@0.8.13':
resolution: {integrity: sha512-Dp/063qdrbe096z6IpIneazsqscfzSwsg09ZNHB6c3OsVb6iLEXLe7mEpATGcRsZ8memuFNLVLIP51eVkBqPcQ==} resolution: {integrity: sha512-zjjKbGG8wQyyuTtm7/7lAGEFbreTp7IO5Y+DZXwBBu/h2/TP/C/v0J0XrshFBqs/wOOURv7vYZlf/bs2En8UIg==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12 '@welshman/net': 0.8.13
'@welshman/router': 0.8.12 '@welshman/router': 0.8.13
'@welshman/signer': 0.8.12 '@welshman/signer': 0.8.13
'@welshman/util': 0.8.12 '@welshman/util': 0.8.13
'@welshman/lib@0.8.12': '@welshman/lib@0.8.13':
resolution: {integrity: sha512-7Y1GjAcABquWF47A1Jni5JdP+k0GH2yRmEbVhIU+0R0TubCwPAKS38J2LTvtuE9CJMX6hPS9IKEZS6qTOAaVuw==} resolution: {integrity: sha512-fXVoe7zx+jPnqZdRMXLNOJvW+N6E708HSpNGfyBGlu1/OXg70wkEK3r9E67HsBg7pLxnl22tcOYq7r11GhpeFA==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
'@welshman/net@0.8.12': '@welshman/net@0.8.13':
resolution: {integrity: sha512-Ba71jwb8BBwUfPPtWHKYLB0HqeDYK64oqwxfo5bYldtPfGYrlD+a7lHFSZvNyOhvyXygCaIMnaye1QxWAHP8ng==} resolution: {integrity: sha512-k9BQA2lJI1mnQrf3pR8e3QhCluPtWSSPz2ywTDKq+/pdVXXIjrnsblHA/62d6SjCCSV/n5fONQ08YMivPzgtGA==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/util': 0.8.12 '@welshman/util': 0.8.13
'@welshman/router@0.8.12': '@welshman/router@0.8.13':
resolution: {integrity: sha512-Rr7ryBNTvTvjoLsDRMKPuoNJbBv2MgyqN2338p4vVhPMK6MOGM3Nx1og0LpHDGiLlCFuPM8RpParpIWfNnWbKw==} resolution: {integrity: sha512-MJh8YfHpoSsRUI96OnqxnBDoQwjqIMh8N57US0id9cd6iOlkYlVPEUeicJK8Kcl5oT0zmN13UT/4o3d7nZrqcA==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12 '@welshman/net': 0.8.13
'@welshman/util': 0.8.12 '@welshman/util': 0.8.13
'@welshman/signer@0.8.12': '@welshman/signer@0.8.13':
resolution: {integrity: sha512-eO4mw2QOR2d2oCS4zgptkCgjC1s8X+1vNnXfWDbtlEwhk7PD4ySSCkpNVMeElq+uluLOugmKvgDc4gfnIH3p2A==} resolution: {integrity: sha512-VgyKxZhJ/Br0q4H8KPfRWAa8WC0EVUc69dxq/Bt1cl7MTBg1EbzolUJhgOgGDOVO0gAKmWYMCnjNochaQy/Wpg==}
version: 0.8.12 version: 0.8.13
peerDependencies: peerDependencies:
'@noble/curves': ^1.9.7 '@noble/curves': ^1.9.7
'@noble/hashes': ^2.0.1 '@noble/hashes': ^2.0.1
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12 '@welshman/net': 0.8.13
'@welshman/util': 0.8.12 '@welshman/util': 0.8.13
nostr-signer-capacitor-plugin: '*' nostr-signer-capacitor-plugin: '*'
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@welshman/store@0.8.12': '@welshman/store@0.8.13':
resolution: {integrity: sha512-3IUzPRMMVF6Pcw3DaKkfJ4S6bYzVtk6Ze3FnaHs1xJIxkQj9GyBO9VreiovULPW0djsnrP5+1UEN4mKZsG3hXg==} resolution: {integrity: sha512-tnmbaNa8aqFVbklsMZ5y4h9xlHnbwo7o1l6xxJI0hqZnTuXD3IvN5/V58qhfZveUN/Y5Gz2MWQHFWyRBQ71ANg==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12 '@welshman/net': 0.8.13
'@welshman/util': 0.8.12 '@welshman/util': 0.8.13
svelte: ^4.0.0 || ^5.0.0 svelte: ^4.0.0 || ^5.0.0
'@welshman/util@0.8.12': '@welshman/util@0.8.13':
resolution: {integrity: sha512-lgftFt2moXZdN5fuL0RoAnAARV0n0d2+Q56gt7KrBSevjoCbtJgBVX5idvxL5PCEfh81veovJtty6eHxrhQv5A==} resolution: {integrity: sha512-3+CNqJjiHGXKzLOniDqAN4Oe038fV1RRjKiVP0++FDVbq8lShtdcliR7FDg/NTjhhmzivhYqdflNvqjAqOxekA==}
peerDependencies: peerDependencies:
'@noble/curves': ^1.9.7 '@noble/curves': ^1.9.7
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@xml-tools/parser@1.0.11': '@xml-tools/parser@1.0.11':
@@ -5969,6 +5988,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@capacitor/clipboard@8.0.1(@capacitor/core@8.0.1)':
dependencies:
'@capacitor/core': 8.0.1
'@capacitor/core@8.0.1': '@capacitor/core@8.0.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -6006,6 +6029,10 @@ snapshots:
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@capacitor/core': 8.0.1
'@capacitor/share@8.0.1(@capacitor/core@8.0.1)':
dependencies:
'@capacitor/core': 8.0.1
'@capacitor/synapse@1.0.4': {} '@capacitor/synapse@1.0.4': {}
'@capawesome/capacitor-android-dark-mode-support@8.0.0(@capacitor/core@8.0.1)': '@capawesome/capacitor-android-dark-mode-support@8.0.0(@capacitor/core@8.0.1)':
@@ -6610,15 +6637,15 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@pomade/core@0.2.2(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))': '@pomade/core@0.2.3(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.13(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies: dependencies:
'@frostr/bifrost': 1.0.7(typescript@5.9.3) '@frostr/bifrost': 1.0.7(typescript@5.9.3)
'@noble/hashes': 2.0.1 '@noble/hashes': 2.0.1
'@peculiar/x509': 1.14.3 '@peculiar/x509': 1.14.3
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3) '@welshman/net': 0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/signer': 0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/signer': 0.8.13(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/util': 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
cbor-x: 1.6.0 cbor-x: 1.6.0
hash-wasm: 4.12.0 hash-wasm: 4.12.0
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
@@ -7276,26 +7303,26 @@ snapshots:
optionalDependencies: optionalDependencies:
'@vite-pwa/assets-generator': 0.2.6 '@vite-pwa/assets-generator': 0.2.6
'@welshman/app@0.8.12(3074ef6691f94dc03952d8dbc98013a7)': '@welshman/app@0.8.13(ed9ee8a79a580bcb9fa9bb6eb1a69558)':
dependencies: dependencies:
'@pomade/core': 0.2.2(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3)) '@pomade/core': 0.2.3(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.13(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/feeds': 0.8.12(d5b74f0c83250e052e0b96f7ff5804e8) '@welshman/feeds': 0.8.13(29451a19e278ea4a9cf66616f05d5557)
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3) '@welshman/net': 0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))) '@welshman/router': 0.8.13(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/signer': 0.8.13(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/store': 0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0) '@welshman/store': 0.8.13(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
'@welshman/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/util': 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
fuse.js: 7.1.0 fuse.js: 7.1.0
svelte: 5.48.0 svelte: 5.48.0
throttle-debounce: 5.0.2 throttle-debounce: 5.0.2
'@welshman/content@0.8.12(nostr-tools@2.20.0(typescript@5.9.3))': '@welshman/content@0.8.13(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies: dependencies:
'@braintree/sanitize-url': 7.1.1 '@braintree/sanitize-url': 7.1.1
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
'@welshman/editor@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))': '@welshman/editor@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies: dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
'@tiptap/extension-code': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) '@tiptap/extension-code': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
@@ -7310,64 +7337,64 @@ snapshots:
'@tiptap/extension-text': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) '@tiptap/extension-text': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
'@tiptap/pm': 2.27.2 '@tiptap/pm': 2.27.2
'@tiptap/suggestion': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) '@tiptap/suggestion': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/util': 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
nostr-editor: 1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))) nostr-editor: 1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
tippy.js: 6.3.7 tippy.js: 6.3.7
'@welshman/feeds@0.8.12(d5b74f0c83250e052e0b96f7ff5804e8)': '@welshman/feeds@0.8.13(29451a19e278ea4a9cf66616f05d5557)':
dependencies: dependencies:
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3) '@welshman/net': 0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))) '@welshman/router': 0.8.13(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/signer': 0.8.13(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/util': 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
trava: 1.2.1 trava: 1.2.1
'@welshman/lib@0.8.12': '@welshman/lib@0.8.13':
dependencies: dependencies:
'@scure/base': 1.2.6 '@scure/base': 1.2.6
'@types/events': 3.0.3 '@types/events': 3.0.3
events: 3.3.0 events: 3.3.0
'@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)': '@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)':
dependencies: dependencies:
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/util': 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
events: 3.3.0 events: 3.3.0
isomorphic-ws: 5.0.0(ws@8.18.3) isomorphic-ws: 5.0.0(ws@8.18.3)
transitivePeerDependencies: transitivePeerDependencies:
- ws - ws
'@welshman/router@0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))': '@welshman/router@0.8.13(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))':
dependencies: dependencies:
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3) '@welshman/net': 0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/util': 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/signer@0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))': '@welshman/signer@0.8.13(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies: dependencies:
'@noble/curves': 1.9.7 '@noble/curves': 1.9.7
'@noble/hashes': 2.0.1 '@noble/hashes': 2.0.1
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3) '@welshman/net': 0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/util': 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
nostr-signer-capacitor-plugin: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1) nostr-signer-capacitor-plugin: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1)
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
'@welshman/store@0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)': '@welshman/store@0.8.13(@welshman/lib@0.8.13)(@welshman/net@0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)':
dependencies: dependencies:
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
'@welshman/net': 0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3) '@welshman/net': 0.8.13(@welshman/lib@0.8.13)(@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)) '@welshman/util': 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
svelte: 5.48.0 svelte: 5.48.0
'@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))': '@welshman/util@0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies: dependencies:
'@noble/curves': 1.9.7 '@noble/curves': 1.9.7
'@types/ws': 8.18.1 '@types/ws': 8.18.1
'@welshman/lib': 0.8.12 '@welshman/lib': 0.8.13
js-base64: 3.7.8 js-base64: 3.7.8
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
nostr-wasm: 0.1.0 nostr-wasm: 0.1.0
+12 -2
View File
@@ -22,6 +22,16 @@
@apply pl-sai pr-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));
}
@utility py-sai { @utility py-sai {
@apply pt-sai pb-sai; @apply pt-sai pb-sai;
} }
@@ -317,7 +327,7 @@
.note-editor .tiptap { .note-editor .tiptap {
--tiptap-object-bg: var(--color-base-200); --tiptap-object-bg: var(--color-base-200);
@apply input rounded-box h-auto min-h-32 p-[.65rem] pb-6; @apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
} }
.input-editor .tiptap { .input-editor .tiptap {
@@ -415,7 +425,7 @@ body.keyboard-open .hide-on-keyboard {
/* chat view */ /* chat view */
.chat__compose { .chat__compose {
@apply z-compose relative mb-14 grow md:mb-0; @apply z-compose relative mb-14 shrink-0 md:mb-0;
} }
.chat__compose .chat__compose-inner { .chat__compose .chat__compose-inner {
+57
View File
@@ -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<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
@@ -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>(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",
})
}
}
+67 -48
View File
@@ -4,21 +4,35 @@
*/ */
import { import {
DisconnectReason, DisconnectReason,
LocalParticipant,
LocalTrackPublication,
Room as LiveKitRoom, Room as LiveKitRoom,
RoomEvent, RoomEvent,
Track, Track,
supportsAudioOutputSelection, supportsAudioOutputSelection,
type AudioCaptureOptions, type AudioCaptureOptions,
type LocalParticipant,
} from "livekit-client" } from "livekit-client"
import {derived, get, writable} from "svelte/store" import {derived, get} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib" import {map, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app" import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, whenAborted, whenTimeout} from "$lib/util" 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" import {pushToast} from "@app/util/toast"
export const LIVEKIT_PARTICIPANTS = 39004 export const LIVEKIT_PARTICIPANTS = 39004
@@ -27,30 +41,12 @@ export {checkRelayHasLivekit} from "$lib/livekit"
export {supportsAudioOutputSelection} 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<VoiceSession | undefined>(undefined)
const LIVEKIT_DEFAULT_DEVICE_ID = "default" const LIVEKIT_DEFAULT_DEVICE_ID = "default"
export enum DeviceKind { export enum DeviceKind {
AudioInput = "audioinput", AudioInput = "audioinput",
AudioOutput = "audiooutput", AudioOutput = "audiooutput",
VideoInput = "videoinput",
} }
export const switchVoiceActiveDevice = async ( export const switchVoiceActiveDevice = async (
@@ -71,17 +67,14 @@ export const switchVoiceActiveDevice = async (
case DeviceKind.AudioOutput: case DeviceKind.AudioOutput:
label = "speaker" label = "speaker"
break break
case DeviceKind.VideoInput:
label = "camera"
break
} }
pushToast({theme: "error", message: `Error changing ${label}`}) pushToast({theme: "error", message: `Error changing ${label}`})
} }
} }
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
const addParticipant = (identity: string) => { const addParticipant = (identity: string) => {
participantPubkeyMap.update(m => { participantPubkeyMap.update(m => {
const next = new Map(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<VoiceParticipant[]>([])
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
const fetchLivekitToken = async ( const fetchLivekitToken = async (
url: string, url: string,
groupId: string, groupId: string,
@@ -197,7 +172,9 @@ const setUpMicrophone = async (
} }
const onRoomDisconnected = (reason?: DisconnectReason) => { const onRoomDisconnected = (reason?: DisconnectReason) => {
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected) voiceState.set(VoiceState.Disconnected)
const message = const message =
@@ -216,11 +193,16 @@ const onTrackSubscribed = (track: Track) => {
element.style.display = "none" element.style.display = "none"
document.body.appendChild(element) document.body.appendChild(element)
element.play().catch(() => {}) element.play().catch(() => {})
} else if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
} }
} }
const onTrackUnsubscribed = (track: Track) => { const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove()) track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
} }
const onActiveSpeakersChanged = (participants: {identity: string}[]) => { const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
@@ -241,6 +223,17 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
deleteParticipant(participant.identity) 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 let joinAbortController: AbortController | undefined
export const cancelJoinVoiceRoom = () => { export const cancelJoinVoiceRoom = () => {
@@ -278,12 +271,13 @@ export const joinVoiceRoom = async (
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed) liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
try { try {
await Promise.race([ await Promise.race([
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}), 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.", message: "Connection timed out. Please check your network and try again.",
}), }),
whenAborted(signal), whenAborted(signal),
@@ -301,7 +295,14 @@ export const joinVoiceRoom = async (
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant) 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) voiceState.set(VoiceState.Connected)
playJoinSound() playJoinSound()
} catch (e) { } catch (e) {
@@ -320,8 +321,26 @@ export const leaveVoiceRoom = async () => {
const audio = new Audio("/leave-voice-room.mp3") const audio = new Audio("/leave-voice-room.mp3")
audio.play().catch(() => {}) 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) voiceState.set(VoiceState.Disconnected)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
resetVideoCallLayout()
session.room.disconnect() session.room.disconnect()
speakingParticipants.set([]) speakingParticipants.set([])
participantPubkeyMap.set(new Map()) participantPubkeyMap.set(new Map())
@@ -7,12 +7,13 @@
type Props = { type Props = {
url: string url: string
h?: string h?: string
shareToChat?: boolean
} }
const {url, h}: Props = $props() const {url, h, shareToChat = false}: Props = $props()
</script> </script>
<CalendarEventForm {url} {h}> <CalendarEventForm {url} {h} {shareToChat}>
{#snippet header()} {#snippet header()}
<ModalHeader> <ModalHeader>
<ModalTitle>Create an Event</ModalTitle> <ModalTitle>Create an Event</ModalTitle>
+46 -21
View File
@@ -3,7 +3,7 @@
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib" import {randomId, HOUR} from "@welshman/lib"
import {makeEvent, EVENT_TIME} from "@welshman/util" import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} from "@welshman/app" import {publishThunk, waitForThunkError} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util" import {daysBetween} from "@lib/util"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl" import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@@ -22,7 +22,7 @@
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts" import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = { type Values = {
d: string d: string
@@ -36,11 +36,12 @@
type Props = { type Props = {
url: string url: string
h?: string h?: string
shareToChat?: boolean
header: Snippet header: Snippet
initialValues?: Values initialValues?: Values
} }
let {url, h, header, initialValues}: Props = $props() let {url, h, shareToChat = false, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`) const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
@@ -57,7 +58,7 @@
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run()) const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => { const submit = async () => {
if ($uploading) return if ($uploading || loading) return
if (!title) { if (!title) {
return pushToast({ return pushToast({
@@ -92,22 +93,42 @@
...ed.storage.nostr.getEditorTags(), ...ed.storage.nostr.getEditorTags(),
] ]
if (await shouldProtect) { loading = true
tags.push(PROTECTED)
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]})
draftKey.clear()
history.back()
} }
let loading = $state(false)
const d = $state(initialValues?.d ?? randomId()) const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "") let title = $state(initialValues?.title ?? "")
let location = $state(initialValues?.location ?? "") let location = $state(initialValues?.location ?? "")
@@ -158,7 +179,11 @@
<div class="input-editor grow overflow-hidden"> <div class="input-editor grow overflow-hidden">
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}> <Button
data-tip="Add an image"
class="center btn tooltip"
onclick={selectFiles}
disabled={loading}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
@@ -197,12 +222,12 @@
</Field> </Field>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading}> <Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner loading={$uploading}>Save Event</Spinner> <Spinner loading={$uploading || loading}>Save Event</Spinner>
</Button> </Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+1 -1
View File
@@ -279,7 +279,7 @@
</div> </div>
</PageBar> </PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4"> <PageContent class="flex flex-col-reverse gap-2 py-4">
{#if missingRelayLists.length > 0} {#if missingRelayLists.length > 0}
<div class="py-12"> <div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center"> <div class="card2 col-2 m-auto max-w-md items-center text-center">
+3 -2
View File
@@ -7,12 +7,13 @@
type Props = { type Props = {
url: string url: string
h?: string h?: string
shareToChat?: boolean
} }
const {url, h}: Props = $props() const {url, h, shareToChat = false}: Props = $props()
</script> </script>
<ClassifiedForm {url} {h}> <ClassifiedForm {url} {h} {shareToChat}>
{#snippet header()} {#snippet header()}
<ModalHeader> <ModalHeader>
<ModalTitle>Create a Classified Listing</ModalTitle> <ModalTitle>Create a Classified Listing</ModalTitle>
+18 -5
View File
@@ -2,7 +2,7 @@
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {removeUndefined, randomId, uniq} from "@welshman/lib" import {removeUndefined, randomId, uniq} from "@welshman/lib"
import {makeEvent, CLASSIFIED} from "@welshman/util" import {makeEvent, CLASSIFIED} from "@welshman/util"
import {publishThunk} from "@welshman/app" import {publishThunk, waitForThunkError} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import {normalizeTopic} from "@lib/util" import {normalizeTopic} from "@lib/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@@ -21,7 +21,7 @@
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts" import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, uploadFile} from "@app/core/commands" import {canEnforceNip70, publishRoomQuote, uploadFile} from "@app/core/commands"
type Values = { type Values = {
d: string d: string
@@ -37,11 +37,12 @@
type Props = { type Props = {
url: string url: string
h?: string h?: string
shareToChat?: boolean
header: Snippet header: Snippet
initialValues?: Values initialValues?: Values
} }
let {url, h, header, initialValues}: Props = $props() let {url, h, shareToChat = false, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`) const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`)
@@ -87,7 +88,9 @@
tags.push(["t", topic]) tags.push(["t", topic])
} }
if (await shouldProtect) { const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED) tags.push(PROTECTED)
} }
@@ -114,13 +117,23 @@
} }
} }
publishThunk({ const classifiedThunk = publishThunk({
relays: [url], relays: [url],
event: makeEvent(CLASSIFIED, {content, tags}), event: makeEvent(CLASSIFIED, {content, tags}),
}) })
const error = await waitForThunkError(classifiedThunk)
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear() draftKey.clear()
history.back() history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: classifiedThunk.event, protect})
}
} finally { } finally {
loading = false loading = false
} }
+5 -5
View File
@@ -22,15 +22,15 @@
const {url, h, onClick}: Props = $props() const {url, h, onClick}: Props = $props()
const createGoal = () => pushModal(GoalCreate, {url, h}) const createGoal = () => pushModal(GoalCreate, {url, h, shareToChat: true})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h}) const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h, shareToChat: true})
const createThread = () => pushModal(ThreadCreate, {url, h}) const createThread = () => pushModal(ThreadCreate, {url, h, shareToChat: true})
const createClassified = () => pushModal(ClassifiedCreate, {url, h}) const createClassified = () => pushModal(ClassifiedCreate, {url, h, shareToChat: true})
const createPoll = () => pushModal(PollCreate, {url, h}) const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
let ul: Element let ul: Element
+54 -46
View File
@@ -5,30 +5,32 @@
import {preventDefault, stopPropagation} from "@lib/html" import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte" import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte" import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import { import {
dufflepud, dufflepud,
PLATFORM_URL,
IMAGE_CONTENT_TYPES, IMAGE_CONTENT_TYPES,
PLATFORM_URL,
VIDEO_CONTENT_TYPES, VIDEO_CONTENT_TYPES,
THUMBNAIL_URL, THUMBNAIL_URL,
isRoomId,
} from "@app/core/state" } from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props() const {value, event} = $props()
let hideImage = $state(false) let hideImage = $state(false)
const url = value.url.toString() const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || "" const isRoomOrRelay = isRoomId(url) || isRelayUrl(url)
const [href, external] = call(() => { const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false] if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true] return [url, true]
}) })
const fileType = getTagValue("file-type", event.tags) || ""
const getVideoPoster = (videoUrl: string): string | undefined => { const getVideoPoster = (videoUrl: string): string | undefined => {
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) { if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
return `${THUMBNAIL_URL}/thumbnail?url=${encodeURIComponent(videoUrl)}` return `${THUMBNAIL_URL}/thumbnail?url=${encodeURIComponent(videoUrl)}`
@@ -54,46 +56,52 @@
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true}) const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script> </script>
<Link {external} {href} class="my-2 block"> {#if isRoomOrRelay}
<div class="overflow-hidden rounded-box"> <div>
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)} <ContentLinkUrl {url} class="link-content whitespace-nowrap" />
<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> </div>
</Link> {: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}
+5 -15
View File
@@ -1,25 +1,18 @@
<script lang="ts"> <script lang="ts">
import {call, displayUrl} from "@welshman/lib" import {displayUrl} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util" import {getTagValue} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html" import {preventDefault, stopPropagation} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte" import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state" import {IMAGE_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props() const {value, event} = $props()
const url = value.url.toString() const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || "" 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}) const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script> </script>
@@ -34,8 +27,5 @@
{displayUrl(url)} {displayUrl(url)}
</a> </a>
{:else} {:else}
<Link {external} {href} class="link-content whitespace-nowrap"> <ContentLinkUrl {url} class="link-content whitespace-nowrap" />
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</Link>
{/if} {/if}
+59
View File
@@ -0,0 +1,59 @@
<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
@@ -49,7 +49,7 @@
<NoteContentMinimal trimParent {url} event={$quote} /> <NoteContentMinimal trimParent {url} event={$quote} />
</div> </div>
{:else} {:else}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4"> <NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContentMinimal {url} event={$quote} /> <NoteContentMinimal {url} event={$quote} />
</NoteCard> </NoteCard>
{/if} {/if}
+2 -2
View File
@@ -68,7 +68,7 @@
}) })
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
spacer!.style.minHeight = `${form!.offsetHeight}px` spacer!.style.minHeight = `${form!.offsetHeight + 60}px`
}) })
observer.observe(form!) observer.observe(form!)
@@ -84,7 +84,7 @@
in:fly in:fly
bind:this={form} bind:this={form}
onsubmit={preventDefault(submit)} onsubmit={preventDefault(submit)}
class="left-content bottom-sai right-sai ml-2 pl-2 fixed z-feature"> class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
<div class="card2 mx-2 my-2 bg-alt shadow-md"> <div class="card2 mx-2 my-2 bg-alt shadow-md">
<div class="relative"> <div class="relative">
<div class="note-editor grow overflow-hidden"> <div class="note-editor grow overflow-hidden">
+50 -25
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {makeEvent, ZAP_GOAL} from "@welshman/util" import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publishThunk} from "@welshman/app" import {publishThunk, waitForThunkError} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl" import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
@@ -10,6 +10,7 @@
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -21,7 +22,7 @@
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts" import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = { type Values = {
title: string title: string
@@ -33,9 +34,10 @@
url: string url: string
h?: string h?: string
initialValues?: Values initialValues?: Values
shareToChat?: boolean
} }
let {url, h, initialValues}: Props = $props() let {url, h, initialValues, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`) const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`)
@@ -52,7 +54,7 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles()) const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => { const submit = async () => {
if ($uploading) return if ($uploading || loading) return
if (!title) { if (!title) {
return pushToast({ return pushToast({
@@ -78,23 +80,43 @@
["relays", url], ["relays", url],
] ]
if (await shouldProtect) { loading = true
tags.push(PROTECTED)
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 (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content: title, tags}),
})
draftKey.clear()
history.back()
} }
let loading = $state(false)
let title = $state(initialValues?.title ?? "") let title = $state(initialValues?.title ?? "")
let amount = $state(initialValues?.amount ?? 1000) let amount = $state(initialValues?.amount ?? 1000)
let content = $state(initialValues?.content ?? "") let content = $state(initialValues?.content ?? "")
@@ -154,7 +176,8 @@
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2" class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}> onclick={selectFiles}
disabled={loading}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
@@ -169,16 +192,16 @@
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex grow justify-end"> <div class="flex grow justify-end">
<label class="input input-bordered flex items-center gap-2"> <label class="input input-bordered flex w-auto items-center gap-2">
<Icon icon={Bolt} /> <Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-28" /> <input bind:value={amount} type="number" class="w-28 grow" />
<p class="opacity-50">sats</p> <p class="shrink-0 opacity-50">sats</p>
</label> </label>
</div> </div>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
<input <input
class="range range-primary -mt-2" class="range range-primary -mt-2 w-full"
type="range" type="range"
min="1000" min="1000"
max="100000" max="100000"
@@ -188,10 +211,12 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary">Create Goal</Button> <Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Goal</Spinner>
</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+10
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {Capacitor} from "@capacitor/core"
import {onMount, onDestroy} from "svelte" import {onMount, onDestroy} from "svelte"
import type {Nip46ResponseWithResult} from "@welshman/signer" import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker} from "@welshman/signer" import {Nip46Broker} from "@welshman/signer"
@@ -103,10 +104,16 @@
mode = "connect" mode = "connect"
} }
const openSigner = () => {
controller.launchSigner()
}
const selectBunker = () => { const selectBunker = () => {
mode = "bunker" mode = "bunker"
} }
const isIos = Capacitor.getPlatform() === "ios"
let mode: string = $state("bunker") let mode: string = $state("bunker")
$effect(() => { $effect(() => {
@@ -138,6 +145,9 @@
<BunkerUrl {controller} /> <BunkerUrl {controller} />
<Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect} <Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect}
>Log in with a QR code instead</Button> >Log in with a QR code instead</Button>
{#if isIos}
<Button class="btn btn-neutral" onclick={openSigner}>Open in Signer</Button>
{/if}
{/if} {/if}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+3 -1
View File
@@ -16,6 +16,7 @@
children, children,
minimal = false, minimal = false,
hideProfile = false, hideProfile = false,
noShadow = false,
url, url,
...restProps ...restProps
}: { }: {
@@ -23,6 +24,7 @@
children: Snippet children: Snippet
minimal?: boolean minimal?: boolean
hideProfile?: boolean hideProfile?: boolean
noShadow?: boolean
url?: string url?: string
class?: string class?: string
} = $props() } = $props()
@@ -34,7 +36,7 @@
let muted = $state($isEventMuted(event)) let muted = $state($isEventMuted(event))
</script> </script>
<div class="flex flex-col gap-2 shadow-md {restProps.class}"> <div class="flex flex-col gap-2 {restProps.class}" class:shadow-md={!noShadow}>
{#if muted} {#if muted}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="row-2 relative"> <div class="row-2 relative">
+2 -3
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util" import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte" import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
import NoteContentThread from "@app/components/NoteContentThread.svelte" import NoteContentThread from "@app/components/NoteContentThread.svelte"
import NoteContentClassified from "@app/components/NoteContentClassified.svelte" import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
@@ -21,7 +20,7 @@
<NoteContentClassified {...props} /> <NoteContentClassified {...props} />
{:else if props.event.kind === ZAP_GOAL} {:else if props.event.kind === ZAP_GOAL}
<NoteContentGoal {...props} /> <NoteContentGoal {...props} />
{:else if props.event.kind === Poll} {:else if props.event.kind === POLL}
<NoteContentPoll {...props} /> <NoteContentPoll {...props} />
{:else} {:else}
<Content {...props} /> <Content {...props} />
+2 -3
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util" import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte" import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte" import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte" import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
@@ -21,7 +20,7 @@
<NoteContentMinimalClassified {...props} /> <NoteContentMinimalClassified {...props} />
{:else if props.event.kind === ZAP_GOAL} {:else if props.event.kind === ZAP_GOAL}
<NoteContentMinimalGoal {...props} /> <NoteContentMinimalGoal {...props} />
{:else if props.event.kind === Poll} {:else if props.event.kind === POLL}
<NoteContentMinimalPoll {...props} /> <NoteContentMinimalPoll {...props} />
{:else} {:else}
<ContentMinimal {...props} /> <ContentMinimal {...props} />
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {PollResponse} from "nostr-tools/kinds" import {POLL_RESPONSE} from "@welshman/util"
import ContentMinimal from "@app/components/ContentMinimal.svelte" import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {deriveEvents} from "@app/core/state" import {deriveEvents} from "@app/core/state"
import {getPollResults} from "@app/util/polls" import {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof ContentMinimal> = $props() const props: ComponentProps<typeof ContentMinimal> = $props()
const responses = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}]) const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [props.event.id]}])
const results = derived(responses, $responses => getPollResults(props.event, $responses)) const results = derived(responses, $responses => getPollResults(props.event, $responses))
</script> </script>
+2 -2
View File
@@ -2,7 +2,7 @@
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {onMount} from "svelte" import {onMount} from "svelte"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import {PollResponse} from "nostr-tools/kinds" import {POLL_RESPONSE} from "@welshman/util"
import PollVotes from "@app/components/PollVotes.svelte" import PollVotes from "@app/components/PollVotes.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
@@ -15,7 +15,7 @@
request({ request({
relays: [props.url], relays: [props.url],
filters: [{kinds: [PollResponse], "#e": [props.event.id]}], filters: [{kinds: [POLL_RESPONSE], "#e": [props.event.id]}],
}) })
}) })
</script> </script>
+42 -17
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib" import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent} from "@welshman/util" import {makeEvent, POLL} from "@welshman/util"
import {publishThunk} from "@welshman/app" import {publishThunk, waitForThunkError} from "@welshman/app"
import {Poll} from "nostr-tools/kinds"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl" import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
@@ -13,6 +12,7 @@
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte" import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -21,7 +21,7 @@
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
import {DraftKey} from "@app/util/drafts" import {DraftKey} from "@app/util/drafts"
import type {PollType} from "@app/util/polls" import type {PollType} from "@app/util/polls"
@@ -40,9 +40,10 @@
type Props = { type Props = {
url: string url: string
h?: string h?: string
shareToChat?: boolean
} }
const {url, h}: Props = $props() const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`) const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
const initialValues = draftKey.get() const initialValues = draftKey.get()
@@ -102,6 +103,8 @@
} }
const submit = async () => { const submit = async () => {
if (loading) return
if (!title.trim()) { if (!title.trim()) {
return pushToast({theme: "error", message: "Please provide a title for your poll."}) return pushToast({theme: "error", message: "Please provide a title for your poll."})
} }
@@ -130,19 +133,39 @@
tags.push(["h", h]) tags.push(["h", h])
} }
if (await shouldProtect) { loading = true
tags.push(PROTECTED)
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
} }
publishThunk({
relays: [url],
event: makeEvent(Poll, {content: title.trim(), tags}),
})
draftKey.clear()
history.back()
} }
let loading = $state(false)
let draggedOptionId = $state<string | undefined>() let draggedOptionId = $state<string | undefined>()
let title = $state(initialValues?.title ?? "") let title = $state(initialValues?.title ?? "")
let pollType = $state<PollType>(initialValues?.pollType ?? "singlechoice") let pollType = $state<PollType>(initialValues?.pollType ?? "singlechoice")
@@ -246,10 +269,12 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary">Create Poll</Button> <Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Create Poll</Spinner>
</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {POLL_RESPONSE} from "@welshman/util"
import {pubkey, publishThunk, abortThunk} from "@welshman/app" import {pubkey, publishThunk, abortThunk} from "@welshman/app"
import {PollResponse} from "nostr-tools/kinds"
import {formatTimestampRelative} from "@welshman/lib" import {formatTimestampRelative} from "@welshman/lib"
import {deriveEvents} from "@app/core/state" import {deriveEvents} from "@app/core/state"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -24,7 +24,7 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const responses = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}]) const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [event.id]}])
const pollType = getPollType(event) const pollType = getPollType(event)
const options = getPollOptions(event) const options = getPollOptions(event)
+3 -3
View File
@@ -5,11 +5,11 @@
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {deriveEventsDesc, deriveEventsById} from "@welshman/store" import {deriveEventsDesc, deriveEventsById} from "@welshman/store"
import {formatTimestampRelative} from "@welshman/lib" import {formatTimestampRelative} from "@welshman/lib"
import {NOTE, ROOMS, COMMENT} from "@welshman/util" import {NOTE, ROOMS, COMMENT, MESSAGE} from "@welshman/util"
import {repository, loadRelayList} from "@welshman/app" import {repository, loadRelayList} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileSpaces from "@app/components/ProfileSpaces.svelte" import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
import {deriveGroupList, getSpaceUrlsFromGroupList, MESSAGE_KINDS} from "@app/core/state" import {deriveGroupList, getSpaceUrlsFromGroupList} from "@app/core/state"
import {goToEvent} from "@app/util/routes" import {goToEvent} from "@app/util/routes"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -36,7 +36,7 @@
load({ load({
filters: [ filters: [
{authors: [pubkey], kinds: [ROOMS]}, {authors: [pubkey], kinds: [ROOMS]},
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, ...MESSAGE_KINDS]}, {authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, MESSAGE]},
], ],
relays: Router.get().FromPubkeys([pubkey]).getUrls(), relays: Router.get().FromPubkeys([pubkey]).getUrls(),
}) })
+4 -12
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import {getTag, makeProfile} from "@welshman/util" import {makeProfile} from "@welshman/util"
import {pubkey, profilesByPubkey, waitForThunkError} from "@welshman/app" import {pubkey, profilesByPubkey, waitForThunkError} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import {errorMessage} from "@lib/util" import {errorMessage} from "@lib/util"
@@ -10,26 +10,18 @@
import ProfileEditForm from "@app/components/ProfileEditForm.svelte" import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import {clearModals} from "@app/util/modal" import {clearModals} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {updateProfile} from "@app/core/commands" import {updateProfile} from "@app/core/commands"
const profile = $profilesByPubkey.get($pubkey!) || makeProfile() const profile = $profilesByPubkey.get($pubkey!) || makeProfile()
const shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || []) const initialValues = {profile}
const initialValues = {profile, shouldBroadcast}
const back = () => history.back() const back = () => history.back()
const onsubmit = async ({ const onsubmit = async ({profile}: {profile: Profile}) => {
profile,
shouldBroadcast,
}: {
profile: Profile
shouldBroadcast: boolean
}) => {
loading = true loading = true
try { try {
const error = await waitForThunkError(updateProfile({profile, shouldBroadcast})) const error = await waitForThunkError(updateProfile({profile}))
if (error) { if (error) {
pushToast({ pushToast({
+1 -22
View File
@@ -6,7 +6,6 @@
import MapPoint from "@assets/icons/map-point.svg?dataurl" import MapPoint from "@assets/icons/map-point.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
@@ -17,7 +16,6 @@
type Values = { type Values = {
profile: Profile profile: Profile
shouldBroadcast: boolean
} }
type Props = { type Props = {
@@ -77,7 +75,7 @@
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<textarea <textarea
class="textarea textarea-bordered leading-4" class="textarea textarea-bordered leading-4 w-full"
rows="5" rows="5"
bind:value={values.profile.about}></textarea> bind:value={values.profile.about}></textarea>
{/snippet} {/snippet}
@@ -104,25 +102,6 @@
{/snippet} {/snippet}
</Field> </Field>
{/if} {/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> </ModalBody>
<ModalFooter> <ModalFooter>
{@render footer()} {@render footer()}
+17 -3
View File
@@ -33,6 +33,7 @@
url?: string url?: string
reactionClass?: string reactionClass?: string
noTooltip?: boolean noTooltip?: boolean
innerEvent?: TrustedEvent
children?: Snippet children?: Snippet
} }
@@ -43,23 +44,36 @@
url = "", url = "",
reactionClass = "", reactionClass = "",
noTooltip = false, noTooltip = false,
innerEvent = undefined,
children, children,
}: Props = $props() }: Props = $props()
const eventIds = innerEvent ? [event.id, innerEvent.id] : [event.id]
const reports = deriveArray( const reports = deriveArray(
deriveEventsById({repository, filters: [{kinds: [REPORT], "#e": [event.id]}]}), deriveEventsById({repository, filters: [{kinds: [REPORT], "#e": [event.id]}]}),
) )
const reactions = deriveArray( const reactions = deriveArray(
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": [event.id]}]}), deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": eventIds}]}),
) )
const zaps = deriveArray( const zaps = deriveArray(
deriveItemsByKey<Zap>({ deriveItemsByKey<Zap>({
repository, repository,
getKey: zap => zap.response.id, getKey: zap => zap.response.id,
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}], filters: [{kinds: [ZAP_RESPONSE], "#e": eventIds}],
eventToItem: (response: TrustedEvent) => getValidZap(response, event), eventToItem: (response: TrustedEvent) => {
const zap = getValidZap(response, event)
if (zap) {
return zap
}
if (innerEvent) {
return getValidZap(response, innerEvent)
}
},
}), }),
) )
+5 -1
View File
@@ -243,7 +243,7 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if $members.length > 0} {#if $members !== undefined && $members.length > 0}
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4"> <div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span>Members:</span> <span>Members:</span>
@@ -251,6 +251,10 @@
</div> </div>
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button> <Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
</div> </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} {/if}
<div class="card2 card2-sm bg-alt col-4"> <div class="card2 card2-sm bg-alt col-4">
<strong class="text-lg">Room Settings</strong> <strong class="text-lg">Room Settings</strong>
+27 -12
View File
@@ -1,8 +1,16 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {hash, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib" import {readable} from "svelte/store"
import {
hash,
gte,
now,
displayList,
formatTimestampAsTime,
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {MESSAGE, COMMENT} from "@welshman/util" import {MESSAGE, COMMENT, getTag} from "@welshman/util"
import { import {
thunks, thunks,
pubkey, pubkey,
@@ -27,7 +35,7 @@
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte" import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte" import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
import RoomItemContent from "@app/components/RoomItemContent.svelte" import RoomItemContent from "@app/components/RoomItemContent.svelte"
import {colors, ENABLE_ZAPS, deriveEventsForUrl} from "@app/core/state" import {colors, ENABLE_ZAPS, deriveEventsForUrl, deriveEvent} from "@app/core/state"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands" import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {getRoomItemPath} from "@app/util/routes" import {getRoomItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -38,7 +46,6 @@
replyTo?: (event: TrustedEvent) => void replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean showPubkey?: boolean
addSpaceBelow?: boolean addSpaceBelow?: boolean
inert?: boolean
canEdit: (event: TrustedEvent) => boolean canEdit: (event: TrustedEvent) => boolean
onEdit: (event: TrustedEvent) => void onEdit: (event: TrustedEvent) => void
} }
@@ -49,7 +56,6 @@
replyTo = undefined, replyTo = undefined,
showPubkey = false, showPubkey = false,
addSpaceBelow = false, addSpaceBelow = false,
inert = false,
canEdit, canEdit,
onEdit, onEdit,
}: Props = $props() }: Props = $props()
@@ -60,7 +66,15 @@
const profileDisplay = deriveProfileDisplay(event.pubkey, [url]) const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id)) const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
const [_, colorValue] = colors[hash(event.pubkey) % colors.length] const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [event.id]}])
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 reply = () => replyTo!(event) const reply = () => replyTo!(event)
const edit = canEdit(event) ? () => onEdit(event) : undefined const edit = canEdit(event) ? () => onEdit(event) : undefined
@@ -78,7 +92,7 @@
<TapTarget <TapTarget
data-event={event.id} data-event={event.id}
onTap={inert ? null : onTap} {onTap}
class={cx( class={cx(
"group relative flex w-full cursor-default flex-col px-2 py-0.5 text-left hover:bg-base-100/50", "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}, {"mt-1.5": showPubkey, "mb-1.5": addSpaceBelow},
@@ -111,7 +125,7 @@
</div> </div>
{/if} {/if}
<div class:mt-2={showPubkey && event.kind !== MESSAGE}> <div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<RoomItemContent {url} {event} /> <RoomItemContent {url} event={$innerEvent ?? event} />
{#if thunk} {#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" /> <ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if} {/if}
@@ -124,9 +138,10 @@
{event} {event}
{deleteReaction} {deleteReaction}
{createReaction} {createReaction}
reactionClass="tooltip-right" /> reactionClass="tooltip-right"
{#if path && $comments.length > 0} innerEvent={$innerEvent} />
{@const pubkeys = $comments.map(e => e.pubkey)} {#if path && $innerComments.length > 0}
{@const pubkeys = $innerComments.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)} {@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))} {@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} commented`} {@const tooltip = `${info} commented`}
@@ -138,7 +153,7 @@
"btn-primary": isOwn, "btn-primary": isOwn,
})}> })}>
<Icon icon={ReplyAlt} /> <Icon icon={ReplyAlt} />
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span> <span>{$innerComments.length} comment{$innerComments.length === 1 ? "" : "s"}</span>
</Link> </Link>
</div> </div>
{/if} {/if}
+4 -3
View File
@@ -8,16 +8,17 @@
import {getRoomItemPath} from "@app/util/routes" import {getRoomItemPath} from "@app/util/routes"
const props: ComponentProps<typeof NoteContent> = $props() const props: ComponentProps<typeof NoteContent> = $props()
const path = getRoomItemPath(props.url!, props.event) const path = getRoomItemPath(props.url!, props.event)
const minLength = 5000
const maxLength = 5500
</script> </script>
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}> <div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
{#if path && !isMobile} {#if path && !isMobile}
<Link href={path}> <Link href={path}>
<NoteContent {...props} /> <NoteContent {...props} {minLength} {maxLength} />
</Link> </Link>
{:else} {:else}
<NoteContent {...props} /> <NoteContent {...props} {minLength} {maxLength} />
{/if} {/if}
</div> </div>
+36 -26
View File
@@ -73,34 +73,44 @@
</ModalSubtitle> </ModalSubtitle>
</ModalHeader> </ModalHeader>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each $members as pubkey (pubkey)} {#if $members === undefined}
<div class="card2 bg-alt relative"> <div class="card2 bg-base-200 p-4">
<div class="flex items-center justify-between gap-2"> <span class="text-error">Member list not available from this relay</span>
<div class="min-w-0 flex-1"> </div>
<Profile {pubkey} {url} /> {:else if $members.length === 0}
</div> <div class="card2 bg-base-200 p-4">
<div class="relative"> <span class="text-base-content/70">No members yet</span>
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}> </div>
<Icon icon={MenuDots} /> {:else}
</Button> {#each $members as pubkey (pubkey)}
{#if menuPubkey === pubkey} <div class="card2 bg-alt relative">
<Popover hideOnClick onClose={closeMenu}> <div class="flex items-center justify-between gap-2">
<ul <div class="min-w-0 flex-1">
transition:fly <Profile {pubkey} {url} />
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md"> </div>
<li> <div class="relative">
<Button class="text-error" onclick={() => removeMember(pubkey)}> <Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MinusCircle} /> <Icon icon={MenuDots} />
Remove Member </Button>
</Button> {#if menuPubkey === pubkey}
</li> <Popover hideOnClick onClose={closeMenu}>
</ul> <ul
</Popover> transition:fly
{/if} 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> </div>
</div> </div>
</div> {/each}
{/each} {/if}
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+5
View File
@@ -56,6 +56,11 @@
} }
const onSubmit = async () => { const onSubmit = async () => {
if (!$spaceMembers) {
addMembers()
return
}
const pubkeysSnapshot = $state.snapshot(pubkeys) const pubkeysSnapshot = $state.snapshot(pubkeys)
const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey)) const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey))
+1 -1
View File
@@ -12,7 +12,7 @@
</script> </script>
<div class="flex grow items-center justify-between gap-4 {props.class}"> <div class="flex grow items-center justify-between gap-4 {props.class}">
<div class="flex items-center gap-3"> <div class="flex items-center gap-2">
<RoomImage {url} {h} /> <RoomImage {url} {h} />
<div class="min-w-0 overflow-hidden text-ellipsis"> <div class="min-w-0 overflow-hidden text-ellipsis">
<RoomName {url} {h} /> <RoomName {url} {h} />
+1 -1
View File
@@ -17,7 +17,7 @@
const profile = getKey<Profile>("signup.profile")! const profile = getKey<Profile>("signup.profile")!
const initialValues = {profile, shouldBroadcast: false} const initialValues = {profile}
const back = () => history.back() const back = () => history.back()
+56 -22
View File
@@ -3,7 +3,9 @@
import {sleep} from "@welshman/lib" import {sleep} from "@welshman/lib"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import {displayRelayUrl, getTagValue, RELAY_INVITE} from "@welshman/util" import {displayRelayUrl, getTagValue, RELAY_INVITE} from "@welshman/util"
import {Share} from "@capacitor/share"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Upload from "@assets/icons/upload.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl" import Copy from "@assets/icons/copy.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
@@ -25,34 +27,54 @@
const authError = deriveRelayAuthError(url) const authError = deriveRelayAuthError(url)
const back = () => history.back() const back = () => history.back()
const copyInvite = () => clip(invite) 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 claim = $state("")
let loading = $state(true) let loading = $state(true)
let invite = $state("") let invite = $state("")
$effect(() => { $effect(() => {
const relay = displayRelayUrl(url) const relay = displayRelayUrl(url)
const params = new URLSearchParams({r: relay, c: claim}).toString() const params = new URLSearchParams({r: relay, c: claim}).toString()
invite = PLATFORM_URL + "/join?" + params invite = PLATFORM_URL + "/join?" + params
}) })
onMount(async () => { onMount(async () => {
const [[event]] = await Promise.all([ try {
request({ const {value} = await Share.canShare()
relays: [url], canShare = value
autoClose: true, } catch {
signal: AbortSignal.timeout(3000), canShare = false
filters: [{kinds: [RELAY_INVITE]}], }
}),
sleep(2000),
])
claim = getTagValue("claim", event?.tags || []) || "" try {
loading = false 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 {
claim = ""
} finally {
loading = false
}
}) })
</script> </script>
@@ -74,16 +96,28 @@
<p class="center">Oops! It looks like you're not a member of this relay.</p> <p class="center">Oops! It looks like you're not a member of this relay.</p>
{:else} {:else}
<div class="flex flex-col items-center gap-6"> <div class="flex flex-col items-center gap-6">
<QRCode code={invite} /> <div class="w-48">
<QRCode code={invite} />
</div>
<Field> <Field>
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <div class="flex w-full gap-2">
<Icon icon={LinkRound} /> {#if canShare}
<input bind:value={invite} class="grow" type="text" /> <Button
<Button onclick={copyInvite}> class="input input-bordered flex shrink-0 w-12 items-center justify-center p-0"
<Icon icon={Copy} /> onclick={shareInvite}>
</Button> <Icon icon={Upload} />
</label> </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>
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
<p> <p>
+51 -39
View File
@@ -112,46 +112,58 @@
{/if} {/if}
{/if} {/if}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each $members as pubkey (pubkey)} {#if $members === undefined}
<div class="card2 card2-sm bg-alt relative"> <div class="card2 bg-base-200 p-4">
<div class="flex items-center justify-between gap-2"> <span class="text-error">Member list not available from this space</span>
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
{#if canBan || canUnallow}
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
{#if canUnallow}
<li>
<Button onclick={() => unallowMember(pubkey)}>
<Icon icon={UserMinus} />
Remove User
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={() => banMember(pubkey)}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
</div> </div>
{/each} {:else if $members.length === 0}
<div class="card2 bg-base-200 p-4">
<span class="text-base-content/70">No members yet</span>
</div>
{:else}
{#each $members as pubkey (pubkey)}
<div class="card2 card2-sm bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
{#if canBan || canUnallow}
<div class="relative">
<Button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
{#if canUnallow}
<li>
<Button onclick={() => unallowMember(pubkey)}>
<Icon icon={UserMinus} />
Remove User
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={() => banMember(pubkey)}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+7 -4
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util" import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import {deriveRelay, deriveRelayDisplay, createSearch, pubkey} from "@welshman/app" import {deriveRelay, deriveRelayDisplay, createSearch, pubkey} from "@welshman/app"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -181,7 +180,11 @@
<li> <li>
<Button onclick={showMembers}> <Button onclick={showMembers}>
<Icon icon={UserRounded} /> <Icon icon={UserRounded} />
View Members ({$members.length}) {#if $members === undefined}
View Members
{:else}
View Members ({$members.length})
{/if}
</Button> </Button>
</li> </li>
{#if $userIsAdmin} {#if $userIsAdmin}
@@ -263,7 +266,7 @@
<Icon icon={CalendarMinimalistic} /> Calendar <Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(Poll)} {#if $spaceKinds.has(POLL)}
<SecondaryNavItem href={pollsPath}> <SecondaryNavItem href={pollsPath}>
<Icon icon={Revote} /> Polls <Icon icon={Revote} /> Polls
</SecondaryNavItem> </SecondaryNavItem>
+46 -21
View File
@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {makeEvent, THREAD} from "@welshman/util" import {makeEvent, THREAD} from "@welshman/util"
import {publishThunk} from "@welshman/app" import {publishThunk, waitForThunkError} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl" import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -19,7 +20,7 @@
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts" import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = { type Values = {
content?: string | object content?: string | object
@@ -29,9 +30,10 @@
type Props = { type Props = {
url: string url: string
h?: string h?: string
shareToChat?: boolean
} }
const {url, h}: Props = $props() const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`) const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
const initialValues = draftKey.get() const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
@@ -43,7 +45,7 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles()) const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => { const submit = async () => {
if ($uploading) return if ($uploading || loading) return
if (!title) { if (!title) {
return pushToast({ return pushToast({
@@ -64,23 +66,43 @@
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]] const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]]
if (await shouldProtect) { loading = true
tags.push(PROTECTED)
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 (h) {
tags.push(["h", h])
}
publishThunk({
relays: [url],
event: makeEvent(THREAD, {content, tags}),
})
draftKey.clear()
history.back()
} }
let loading = $state(false)
let title = $state(initialValues?.title ?? "") let title = $state(initialValues?.title ?? "")
let content = $state(initialValues?.content ?? "") let content = $state(initialValues?.content ?? "")
@@ -138,7 +160,8 @@
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2" class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}> onclick={selectFiles}
disabled={loading}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
@@ -148,10 +171,12 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary">Create Thread</Button> <Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Thread</Spinner>
</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+278
View File
@@ -0,0 +1,278 @@
<script lang="ts">
import cx from "classnames"
import {Track} from "livekit-client"
import {displayProfileByPubkey, loadProfile} from "@welshman/app"
import Pin from "@assets/icons/pin.svg?dataurl"
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 VoiceWidget from "@app/components/VoiceWidget.svelte"
import {get} from "svelte/store"
import {
VideoCallLayout,
isDesktopLayout,
toggleVideoPrimaryTile,
videoCallLayout,
videoCallViewportSync,
ViewportSize,
videoPrimaryTileKey,
} from "@app/call/video"
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores"
type Props = {
layout: VideoCallLayout
mobile?: boolean
url: string
h: string
class?: string
}
type VideoTileData = {
identity: string
isLocal: boolean
trackSid: string
track: Track | undefined
source: Track.Source.Camera | Track.Source.ScreenShare
}
type TileLayout = "spotlight" | "default" | "strip"
const {layout, mobile = false, url, h, 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 isViewingCurrentCallRoom = $derived(
$currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
)
const showVideoContent = $derived(
isViewingCurrentCallRoom &&
(mobile
? layout === VideoCallLayout.Video
: layout === VideoCallLayout.Split || layout === VideoCallLayout.Video),
)
const videoTiles = $derived.by(() => {
const session = $currentVoiceSession
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
return []
}
const room = session.room
const videoTiles: VideoTileData[] = []
const user = room.localParticipant
if (session.cameraOn) {
const localPub = user.getTrackPublication(Track.Source.Camera)
videoTiles.push({
identity: user.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-camera",
track: localPub?.track,
source: Track.Source.Camera,
})
}
if (session.screenShareOn) {
const localPub = user.getTrackPublication(Track.Source.ScreenShare)
videoTiles.push({
identity: user.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-screen",
track: localPub?.track,
source: Track.Source.ScreenShare,
})
}
for (const rp of room.remoteParticipants.values()) {
const camPub = rp.getTrackPublication(Track.Source.Camera)
if (camPub?.isSubscribed && camPub.track) {
videoTiles.push({
identity: rp.identity,
isLocal: false,
trackSid: camPub.trackSid,
track: camPub.track,
source: Track.Source.Camera,
})
}
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
if (screenPub?.isSubscribed && screenPub.track) {
videoTiles.push({
identity: rp.identity,
isLocal: false,
trackSid: screenPub.trackSid,
track: screenPub.track,
source: Track.Source.ScreenShare,
})
}
}
return videoTiles
})
/** 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 primaryTile = $derived.by(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return undefined
return videoTiles.find(t => tileKey(t) === k)
})
const secondaryTiles = $derived.by(() => {
const p = primaryTile
if (p === undefined) return videoTiles
const pk = tileKey(p)
return videoTiles.filter(t => tileKey(t) !== pk)
})
const useSpotlightLayout = $derived(primaryTile !== undefined)
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
$effect(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return
if (!videoTiles.some(t => tileKey(t) === k)) {
videoPrimaryTileKey.set(undefined)
}
})
$effect(() => {
for (const t of videoTiles) {
const pk = pubkeyFromLiveKitIdentity(t.identity)
if (pk) loadProfile(pk)
}
})
const labelFor = (identity: string, source: VideoTileData["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 spotlightHandlerFor = (key: string) => () => {
toggleVideoPrimaryTile(key)
}
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",
className,
),
)
</script>
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
<div
class={cx(
"relative isolate overflow-hidden rounded-box shadow-sm",
layout === "spotlight" && "min-h-0 flex-1",
layout === "default" && "aspect-video w-full min-h-0",
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}
muted={tile.isLocal}
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
class="pointer-events-none absolute inset-0" />
{:else}
<div class="absolute inset-0 flex items-center justify-center">
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
</div>
{/if}
<span
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}
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
<Button
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
aria-pressed={pinned}
class={cx(
"absolute right-1 top-1 z-20 btn btn-xs btn-square btn-ghost",
pinned ? "btn-active bg-primary/25 text-primary" : "bg-base-100/70",
)}
onclick={spotlightHandlerFor(tileKey(tile))}>
<Icon icon={Pin} size={3} />
</Button>
{/if}
</div>
{/snippet}
{#snippet videoPanelBody()}
{#if showTileGrid}
{#if useSpotlightLayout && primaryTile}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{@render videoTile(primaryTile, "spotlight")}
{#if secondaryTiles.length > 0}
<div
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
{#each secondaryTiles as tile (tileKey(tile))}
{@render videoTile(tile, "strip")}
{/each}
</div>
{/if}
</div>
{: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))}
{@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))}
{@render videoTile(tile, "default")}
{/each}
</div>
{/if}
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
<p>No camera or screen share yet.</p>
<p class="text-xs">Use the camera or screen share control to share video.</p>
</div>
{/if}
{/snippet}
{#if showVideoContent}
<div class={panelChrome}>
{#if 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">
<VoiceWidget />
</div>
</div>
{:else}
{@render videoPanelBody()}
{/if}
</div>
{/if}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import type {Track} from "livekit-client"
import cx from "classnames"
type Props = {
track: Track
muted?: boolean
fit?: "cover" | "contain"
class?: string
}
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
let videoElement = $state<HTMLVideoElement | undefined>()
$effect(() => {
const element = videoElement
const activeTrack = track
if (!element) return
activeTrack.attach(element)
return () => {
activeTrack.detach(element)
}
})
</script>
<video
bind:this={videoElement}
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
playsinline
{muted}></video>
@@ -7,13 +7,8 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import { import {currentVoiceSession, type VoiceSession} from "@app/call/stores"
currentVoiceSession, import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice"
DeviceKind,
supportsAudioOutputSelection,
switchVoiceActiveDevice,
type VoiceSession,
} from "@app/voice"
import {popModal} from "@app/util/modal" import {popModal} from "@app/util/modal"
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => { const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
@@ -26,8 +21,10 @@
let audioInputs = $state<MediaDeviceInfo[]>([]) let audioInputs = $state<MediaDeviceInfo[]>([])
let audioOutputs = $state<MediaDeviceInfo[]>([]) let audioOutputs = $state<MediaDeviceInfo[]>([])
let videoInputs = $state<MediaDeviceInfo[]>([])
let selectedInput = $state("") let selectedInput = $state("")
let selectedOutput = $state("") let selectedOutput = $state("")
let selectedVideo = $state("")
const loadDevices = async () => { const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return if (!navigator.mediaDevices?.enumerateDevices) return
@@ -35,9 +32,11 @@
const devices = await navigator.mediaDevices.enumerateDevices() const devices = await navigator.mediaDevices.enumerateDevices()
audioInputs = devices.filter(d => d.kind === "audioinput") audioInputs = devices.filter(d => d.kind === "audioinput")
audioOutputs = devices.filter(d => d.kind === "audiooutput") audioOutputs = devices.filter(d => d.kind === "audiooutput")
videoInputs = devices.filter(d => d.kind === "videoinput")
} catch { } catch {
audioInputs = [] audioInputs = []
audioOutputs = [] audioOutputs = []
videoInputs = []
} }
} }
@@ -55,6 +54,7 @@
} }
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput) selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput) selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
}) })
const onInputChange = () => { const onInputChange = () => {
@@ -65,6 +65,10 @@
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput) void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
} }
const onVideoChange = () => {
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
}
const onDone = () => { const onDone = () => {
popModal() popModal()
} }
@@ -76,8 +80,8 @@
<Modal> <Modal>
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Audio settings</ModalTitle> <ModalTitle>Call settings</ModalTitle>
<ModalSubtitle>Choose microphone and speaker for this call.</ModalSubtitle> <ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
</ModalHeader> </ModalHeader>
<div class="flex flex-col gap-4 pt-2"> <div class="flex flex-col gap-4 pt-2">
<FieldInline> <FieldInline>
@@ -120,6 +124,25 @@
{/snippet} {/snippet}
</FieldInline> </FieldInline>
{/if} {/if}
<FieldInline>
{#snippet label()}
<p>Camera</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={selectedVideo}
onchange={onVideoChange}
aria-label="Camera">
<option value="">Default camera</option>
{#each videoInputs as d (d.deviceId)}
<option value={d.deviceId}>
{d.label || `Camera ${d.deviceId.slice(0, 8)}`}
</option>
{/each}
</select>
{/snippet}
</FieldInline>
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+3 -4
View File
@@ -12,14 +12,13 @@
import {makeRoomId} from "@app/core/state" import {makeRoomId} from "@app/core/state"
import { import {
VoiceState, VoiceState,
deriveVoiceParticipants,
cancelJoinVoiceRoom,
currentVoiceRoom, currentVoiceRoom,
voiceState,
isParticipantSpeaking, isParticipantSpeaking,
participantKey, participantKey,
voiceState,
type VoiceParticipant, type VoiceParticipant,
} from "@app/voice" } from "@app/call/stores"
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
interface Props { interface Props {
url: string url: string
@@ -14,7 +14,7 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util" import {AbortError, TimeoutError} from "$lib/util"
import {displayRoom} from "@app/core/state" import {displayRoom} from "@app/core/state"
import {joinVoiceRoom} from "@app/voice" import {joinVoiceRoom} from "@app/call/voice"
import {popModal} from "@app/util/modal" import {popModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
+137 -24
View File
@@ -1,15 +1,20 @@
<script lang="ts"> <script lang="ts">
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {fly} from "svelte/transition" import {fade, fly} from "svelte/transition"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {page} from "$app/stores" import {page} from "$app/stores"
import cx from "classnames"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl" import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl" import Videocamera from "@assets/icons/videocamera.svg?dataurl"
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
import Monitor from "@assets/icons/monitor.svg?dataurl"
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl" import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl" import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl" import Settings from "@assets/icons/settings.svg?dataurl"
import {Capacitor} from "@capacitor/core"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte" import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
@@ -23,16 +28,23 @@
type Room, type Room,
} from "@app/core/state" } from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes" import {makeRoomPath} from "@app/util/routes"
import {
VideoCallLayout,
isDesktopLayout,
toggleCamera,
toggleScreenShare,
videoCallLayout,
} from "@app/call/video"
import { import {
VoiceState, VoiceState,
currentVoiceSession, currentVoiceSession,
currentVoiceRoom, currentVoiceRoom,
voiceState, voiceState,
leaveVoiceRoom, isLocalSpeaking,
toggleMute, } from "@app/call/stores"
cancelJoinVoiceRoom, import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
} from "@app/voice"
const {relay, h} = $derived($page.params) const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined) const url = $derived(relay ? decodeRelay(relay) : undefined)
@@ -41,6 +53,14 @@
) )
const routeDisplayedRoom = $derived($displayedRoomStore) const routeDisplayedRoom = $derived($displayedRoomStore)
const isViewingCurrentVoiceRoom = $derived(
$currentVoiceRoom !== undefined &&
url !== undefined &&
typeof h === "string" &&
$currentVoiceRoom.url === url &&
$currentVoiceRoom.h === h,
)
const targetRoom = $derived.by((): Room | undefined => { const targetRoom = $derived.by((): Room | undefined => {
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) { if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
return $currentVoiceRoom return $currentVoiceRoom
@@ -66,9 +86,45 @@
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h}) pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
} }
const openAudioSettings = () => { const goToRoom = () => {
if (!targetRoom) return
const path = makeRoomPath(targetRoom.url, targetRoom.h)
if ($page.url.pathname !== path) {
void goto(path)
}
}
const openCallSettings = () => {
pushModal(VoiceCallAudioSettingsDialog) pushModal(VoiceCallAudioSettingsDialog)
} }
const showChatButton = $derived($voiceState === VoiceState.Connected && isViewingCurrentVoiceRoom)
const isChatPanelActive = $derived(
showChatButton &&
(isDesktopLayout.current
? $videoCallLayout === VideoCallLayout.Split
: $videoCallLayout === VideoCallLayout.Chat),
)
const onChatToggle = () => {
if (!showChatButton) return
if (isDesktopLayout.current) {
videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
)
} else {
videoCallLayout.update(p =>
p === VideoCallLayout.Video ? VideoCallLayout.Chat : VideoCallLayout.Video,
)
}
}
const chatUnread = $derived(
targetRoom !== undefined && $notifications.has(makeRoomPath(targetRoom.url, targetRoom.h)),
)
const mediaToggleClass = "center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
</script> </script>
{#if targetRoom} {#if targetRoom}
@@ -76,19 +132,47 @@
in:fly={{y: 60, duration: 350}} in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}} out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3"> class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex flex-col gap-0.5"> <div class="flex items-start justify-between gap-2">
{#if $voiceState === VoiceState.Joining} <button
<span class="text-sm font-semibold text-warning">Joining...</span> type="button"
{:else if $voiceState === VoiceState.Connected} class="min-w-0 flex-1 rounded-lg px-1 py-0.5 text-left outline-none hover:bg-base-200/60 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-base-100"
<span class="text-sm font-semibold text-success">Voice Connected</span> onclick={goToRoom}
{:else} aria-label="Open room {roomName}">
<span class="text-sm font-semibold text-neutral-content">Disconnected</span> <div class="flex flex-col gap-0.5">
{#if $voiceState === VoiceState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === VoiceState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
{/if}
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div>
</button>
{#if showChatButton}
<Button
data-tip="Toggle Chat"
class={cx(
mediaToggleClass,
"relative shrink-0 overflow-visible",
isChatPanelActive && "text-primary",
)}
onclick={onChatToggle}>
<span class="relative inline-flex">
<Icon icon={ChatRound} size={4} />
{#if chatUnread}
<span
transition:fade={{duration: 150}}
class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary ring-2 ring-base-100"
aria-hidden="true"></span>
{/if}
</span>
</Button>
{/if} {/if}
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex flex-wrap items-center gap-2">
{#if $voiceState === VoiceState.Joining} {#if $voiceState === VoiceState.Joining}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
<Button <Button
@@ -100,16 +184,45 @@
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession} {:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
<Button <Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"} data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted class={cx(
? 'btn-error' mediaToggleClass,
: 'btn-ghost'}" "overflow-visible",
!$currentVoiceSession.muted && $isLocalSpeaking && "text-primary",
$currentVoiceSession.muted &&
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
)}
onclick={toggleMute}> onclick={toggleMute}>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} /> <span class="relative inline-flex items-center justify-center overflow-visible">
<Icon icon={Microphone} size={4} />
{#if $currentVoiceSession.muted}
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
aria-hidden="true">
<span
class="h-[1.3px] w-[150%] max-w-none shrink-0 -rotate-45 rounded-full bg-current"
></span>
</span>
{/if}
</span>
</Button> </Button>
<Button <Button
data-tip="Audio settings" data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
onclick={toggleCamera}>
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
</Button>
{#if !Capacitor.isNativePlatform()}
<Button
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4} />
</Button>
{/if}
<Button
data-tip="Call settings"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost" class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={openAudioSettings}> onclick={openCallSettings}>
<Icon icon={Settings} size={4} /> <Icon icon={Settings} size={4} />
</Button> </Button>
<Button <Button
+40 -26
View File
@@ -18,10 +18,10 @@ import {
import {Nip01Signer} from "@welshman/signer" import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor" import type {UploadTask} from "@welshman/editor"
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util" import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
import {PollResponse} from "nostr-tools/kinds"
import { import {
DELETE, DELETE,
REPORT, REPORT,
MESSAGE,
PROFILE, PROFILE,
MESSAGING_RELAYS, MESSAGING_RELAYS,
RELAYS, RELAYS,
@@ -32,6 +32,7 @@ import {
ROOMS, ROOMS,
COMMENT, COMMENT,
APP_DATA, APP_DATA,
POLL_RESPONSE,
isSignedEvent, isSignedEvent,
makeEvent, makeEvent,
normalizeRelayUrl, normalizeRelayUrl,
@@ -52,7 +53,6 @@ import {
isPublishedProfile, isPublishedProfile,
editProfile, editProfile,
createProfile, createProfile,
uniqTags,
ManagementMethod, ManagementMethod,
} from "@welshman/util" } from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net" import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
@@ -84,6 +84,7 @@ import {
SETTINGS, SETTINGS,
PROTECTED, PROTECTED,
INDEXER_RELAYS, INDEXER_RELAYS,
DEFAULT_RELAYS,
DEFAULT_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVERS,
userSpaceUrls, userSpaceUrls,
userSettingsValues, userSettingsValues,
@@ -122,6 +123,34 @@ export const prependParent = (
return {content, tags} return {content, tags}
} }
export const publishRoomQuote = ({
url,
h,
parent,
protect,
delay,
}: {
url: string
h?: string
parent: TrustedEvent
protect: boolean
delay?: number
}) => {
const tags: string[][] = []
if (h) {
tags.push(["h", h])
}
if (protect) {
tags.push(PROTECTED)
}
const event = makeEvent(MESSAGE, prependParent(parent, {content: "", tags}, url))
return publishThunk({relays: [url], event, delay})
}
// Synchronization // Synchronization
export const broadcastUserData = async (relays: string[]) => { export const broadcastUserData = async (relays: string[]) => {
@@ -360,7 +389,7 @@ export type PollResponseParams = {
} }
export const makePollResponse = ({event, selectedIds}: PollResponseParams) => export const makePollResponse = ({event, selectedIds}: PollResponseParams) =>
makeEvent(PollResponse, { makeEvent(POLL_RESPONSE, {
content: "", content: "",
tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])], tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])],
}) })
@@ -695,34 +724,18 @@ export const uploadFile = async (file: File, options: UploadFileOptions = {}) =>
// Update Profile // Update Profile
export const initProfile = (profile: Profile) => { export const initProfile = (profile: Profile) => {
const template = createProfile(profile) const event = makeEvent(PROFILE, createProfile(profile))
// Start out protected by default return publishThunk({event, relays: DEFAULT_RELAYS})
template.tags.push(PROTECTED)
const event = makeEvent(PROFILE, template)
// Don't publish anywhere yet, wait until they join a space
return publishThunk({event, relays: []})
} }
export const updateProfile = ({ export const updateProfile = ({profile}: {profile: Profile}) => {
profile,
shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || []),
}: {
profile: Profile
shouldBroadcast?: boolean
}) => {
const router = Router.get() const router = Router.get()
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const scenarios = [router.FromRelays(get(userSpaceUrls))] const scenarios = [router.FromRelays(get(userSpaceUrls)), router.FromUser(), router.Index()]
if (shouldBroadcast) { // Remove protected tag, we used to add it
scenarios.push(router.FromUser(), router.Index()) template.tags = template.tags.filter(nthNe(0, "-"))
template.tags = template.tags.filter(nthNe(0, "-"))
} else {
template.tags = uniqTags([...template.tags, PROTECTED])
}
const event = makeEvent(template.kind, template) const event = makeEvent(template.kind, template)
const relays = router.merge(scenarios).getUrls() const relays = router.merge(scenarios).getUrls()
@@ -737,9 +750,10 @@ export const addSpaceMembers = async (
pubkeys: string[], pubkeys: string[],
): Promise<string | undefined> => { ): Promise<string | undefined> => {
const spaceMembers = get(deriveSpaceMembers(url)) const spaceMembers = get(deriveSpaceMembers(url))
const results = await Promise.all( const results = await Promise.all(
pubkeys pubkeys
.filter(pubkey => !spaceMembers.includes(pubkey)) .filter(pubkey => !spaceMembers || !spaceMembers.includes(pubkey))
.map(pubkey => .map(pubkey =>
manageRelay(url, { manageRelay(url, {
method: ManagementMethod.AllowPubkey, method: ManagementMethod.AllowPubkey,
+106 -69
View File
@@ -1,5 +1,6 @@
import {get, writable} from "svelte/store" import {writable} from "svelte/store"
import { import {
batch,
call, call,
uniq, uniq,
int, int,
@@ -25,7 +26,8 @@ import {
sortEventsDesc, sortEventsDesc,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util" import type {TrustedEvent, Filter, List} from "@welshman/util"
import {load, request} from "@welshman/net" import {load, request, mergeRepositoryUpdates} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net"
import {repository, loadRelay, tracker} from "@welshman/app" import {repository, loadRelay, tracker} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util" import {daysBetween} from "@lib/util"
@@ -56,57 +58,75 @@ export const makeFeed = ({
let backwardWindow = [at - interval, at] let backwardWindow = [at - interval, at]
let forwardWindow = [at, at + interval] let forwardWindow = [at, at + interval]
const insertEvent = (event: TrustedEvent) => { const insertIntoBuffer = (event: TrustedEvent) => {
let handled = false for (let i = 0; i < buffer.length; i++) {
if (buffer[i].created_at < event.created_at) {
buffer.splice(i, 0, event)
return
}
}
buffer.push(event)
}
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) { // Batch-insert events into the visible store with a single update
const $events = get(events) const insertEvents = (newEvents: TrustedEvent[]) => {
const visible: TrustedEvent[] = []
for (let i = 0; i < $events.length; i++) { for (const event of newEvents) {
if ($events[i].created_at > event.created_at) { if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
events.set(insertAt(i, event, $events)) visible.push(event)
handled = true } else {
break insertIntoBuffer(event)
}
}
if (visible.length > 0) {
events.update($events => {
for (const event of visible) {
let inserted = false
for (let i = 0; i < $events.length; i++) {
if ($events[i].created_at > event.created_at) {
$events = insertAt(i, event, $events)
inserted = true
break
}
}
if (!inserted) {
$events = [...$events, event]
}
} }
} return $events
})
if (!handled) {
events.set([...$events, event])
}
} else {
for (let i = 0; i < buffer.length; i++) {
if (buffer[i].created_at > event.created_at) {
buffer.splice(i, 0, event)
handled = true
break
}
}
if (!handled) {
buffer.push(event)
}
} }
} }
const unsubscribers = [ const unsubscribers = [
on(repository, "update", ({added, removed}) => { on(
if (removed.size > 0) { repository,
buffer = buffer.filter(e => !removed.has(e.id)) "update",
events.update($events => $events.filter(e => !removed.has(e.id))) batch(150, (updates: RepositoryUpdate[]) => {
} const {added, removed} = mergeRepositoryUpdates(updates)
for (const event of added) { if (removed.size > 0) {
if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) { buffer = buffer.filter(e => !removed.has(e.id))
insertEvent(event) events.update($events => $events.filter(e => !removed.has(e.id)))
} }
}
}), const matching = added.filter(
event => matchFilters(filters, event) && tracker.getRelays(event.id).has(url),
)
if (matching.length > 0) {
insertEvents(matching)
}
}),
),
on(tracker, "add", (id: string, trackerUrl: string) => { on(tracker, "add", (id: string, trackerUrl: string) => {
if (trackerUrl === url) { if (trackerUrl === url) {
const event = repository.getEvent(id) const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) { if (event && matchFilters(filters, event)) {
insertEvent(event) insertEvents([event])
} }
} }
}), }),
@@ -132,17 +152,15 @@ export const makeFeed = ({
element, element,
delay: 300, delay: 300,
threshold: 5000, threshold: 5000,
onScroll: () => { onScroll: async () => {
const [since, until] = backwardWindow const [since, until] = backwardWindow
backwardWindow = [since - interval, since] backwardWindow = [since - interval, since]
for (const event of buffer.splice(0, 30)) { insertEvents(buffer.splice(0, 30))
insertEvent(event)
}
if (until > now() - int(2, YEAR)) { if (until > now() - int(2, YEAR)) {
loadTimeframe(since, until) await loadTimeframe(since, until)
} else if (!buffer.some(e => e.created_at < at)) { } else if (!buffer.some(e => e.created_at < at)) {
backwardScroller.stop() backwardScroller.stop()
onBackwardExhausted?.() onBackwardExhausted?.()
@@ -155,17 +173,15 @@ export const makeFeed = ({
reverse: true, reverse: true,
delay: 300, delay: 300,
threshold: 5000, threshold: 5000,
onScroll: () => { onScroll: async () => {
const [since, until] = forwardWindow const [since, until] = forwardWindow
forwardWindow = [until, until + interval] forwardWindow = [until, until + interval]
for (const event of buffer.splice(0, 30)) { insertEvents(buffer.splice(0, 30))
insertEvent(event)
}
if (until < now()) { if (until < now()) {
loadTimeframe(since, until) await loadTimeframe(since, until)
} else if (!buffer.some(e => e.created_at > at)) { } else if (!buffer.some(e => e.created_at > at)) {
forwardScroller.stop() forwardScroller.stop()
onForwardExhausted?.() onForwardExhausted?.()
@@ -208,40 +224,61 @@ export const makeCalendarFeed = ({
const events = writable(sortBy(getStart, getEventsForUrl(url, filters))) const events = writable(sortBy(getStart, getEventsForUrl(url, filters)))
const insertEvent = (event: TrustedEvent) => { // Batch-insert calendar events into the store with a single update
const start = getStart(event) const insertEvents = (newEvents: TrustedEvent[]) => {
const address = getAddress(event) const valid = newEvents.filter(e => !isNaN(getStart(e)) && !isNaN(getEnd(e)))
if (valid.length === 0) return
if (isNaN(start) || isNaN(getEnd(event))) return
events.update($events => { events.update($events => {
for (let i = 0; i < $events.length; i++) { for (const event of valid) {
if ($events[i].id === event.id) return $events const start = getStart(event)
if (getStart($events[i]) > start) return insertAt(i, event, $events) const address = getAddress(event)
}
return [...$events.filter(e => getAddress(e) !== address), event] let handled = false
for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) {
handled = true
break
}
if (getStart($events[i]) > start) {
$events = insertAt(i, event, $events)
handled = true
break
}
}
if (!handled) {
$events = [...$events.filter(e => getAddress(e) !== address), event]
}
}
return $events
}) })
} }
const unsubscribers = [ const unsubscribers = [
on(repository, "update", ({added, removed}) => { on(
if (removed.size > 0) { repository,
events.update($events => $events.filter(e => !removed.has(e.id))) "update",
} batch(150, (updates: RepositoryUpdate[]) => {
const {added, removed} = mergeRepositoryUpdates(updates)
for (const event of added) { if (removed.size > 0) {
if (matchFilters(filters, event)) { events.update($events => $events.filter(e => !removed.has(e.id)))
insertEvent(event)
} }
}
}), const matching = added.filter(event => matchFilters(filters, event))
if (matching.length > 0) {
insertEvents(matching)
}
}),
),
on(tracker, "add", (id: string, trackerUrl: string) => { on(tracker, "add", (id: string, trackerUrl: string) => {
if (trackerUrl === url) { if (trackerUrl === url) {
const event = repository.getEvent(id) const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) { if (event && matchFilters(filters, event)) {
insertEvent(event) insertEvents([event])
} }
} }
}), }),
+167 -104
View File
@@ -3,12 +3,10 @@ import {context as pomadeContext} from "@pomade/core"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import {derived, readable, writable} from "svelte/store" import {derived, readable, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {Poll} from "nostr-tools/kinds"
import { import {
on, on,
gt, gt,
max, max,
find,
spec, spec,
call, call,
first, first,
@@ -94,6 +92,7 @@ import {
THREAD, THREAD,
CLASSIFIED, CLASSIFIED,
WRAP, WRAP,
POLL,
PROFILE, PROFILE,
ZAP_GOAL, ZAP_GOAL,
ZAP_REQUEST, ZAP_REQUEST,
@@ -328,9 +327,7 @@ if (ENABLE_ZAPS) {
REACTION_KINDS.push(ZAP_RESPONSE) REACTION_KINDS.push(ZAP_RESPONSE)
} }
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, Poll] export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, POLL]
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
export const DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE] export const DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]
@@ -555,7 +552,7 @@ export const chatsById = call(() => {
setTimeout(() => { setTimeout(() => {
addEvents(added) addEvents(added)
removeEvents(removed) removeEvents(removed)
}, 50) }, 200)
}), }),
] ]
@@ -569,7 +566,7 @@ export const deriveChat = call(() => {
return (pubkeys: string[]) => _deriveChat(makeChatId(pubkeys)) return (pubkeys: string[]) => _deriveChat(makeChatId(pubkeys))
}) })
export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => { export const chatSearch = derived(throttled(1500, chatsById), $chatsByPubkey => {
return createSearch( return createSearch(
sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())), sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())),
{ {
@@ -596,6 +593,8 @@ export const getRoomType = (room: RoomMeta): RoomType =>
export const makeRoomId = (url: string, h: string) => `${url}'${h}` export const makeRoomId = (url: string, h: string) => `${url}'${h}`
export const isRoomId = (id: string) => id.includes("'")
export const splitRoomId = (id: string) => id.split("'") export const splitRoomId = (id: string) => id.split("'")
export const hasNip29 = (relay?: RelayProfile) => export const hasNip29 = (relay?: RelayProfile) =>
@@ -608,7 +607,7 @@ export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
}) })
export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => { export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => {
const metaByIdByUrl = new Map<string, Map<string, Room>>() const result = new Map<string, Room[]>()
for (const [url, events] of roomMetaEventsByIdByUrl.entries()) { for (const [url, events] of roomMetaEventsByIdByUrl.entries()) {
const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values()) const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values())
@@ -620,6 +619,8 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
} }
} }
const metaById = new Map<string, Room>()
for (const event of metaEvents) { for (const event of metaEvents) {
const meta = tryCatch(() => readRoomMeta(event)) const meta = tryCatch(() => readRoomMeta(event))
@@ -627,22 +628,14 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
continue continue
} }
let metaById = metaByIdByUrl.get(url)
if (!metaById) {
metaById = new Map()
metaByIdByUrl.set(url, metaById)
}
const id = makeRoomId(url, meta.h) const id = makeRoomId(url, meta.h)
metaById.set(id, {...meta, url, id}) metaById.set(id, {...meta, url, id})
} }
}
const result = new Map<string, Room[]>() if (metaById.size > 0) {
result.set(url, Array.from(metaById.values()))
for (const [url, metaById] of metaByIdByUrl.entries()) { }
result.set(url, Array.from(metaById.values()))
} }
return result return result
@@ -816,37 +809,18 @@ export const deriveOtherRooms = (url: string) =>
// Space/room memberships // Space/room memberships
export const deriveSpaceMembers = (url: string) => export const deriveSpaceMembers = (url: string) =>
derived( derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) =>
deriveRelaySignedEvents(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]}]), uniq(getTagValues("member", event?.tags ?? [])),
$events => {
const membersEvent = $events.find(spec({kind: RELAY_MEMBERS}))
if (membersEvent) {
return uniq(getTagValues("member", membersEvent.tags))
}
const members = new Set<string>()
for (const event of sortBy(e => e.created_at, $events)) {
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === RELAY_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === RELAY_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
},
) )
export const deriveRoomMembers = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}]
return derived(deriveEventsForUrl(url, filters), ([event]) =>
uniq(getPubkeyTagValues(event?.tags ?? [])),
)
}
export type BannedPubkeyItem = { export type BannedPubkeyItem = {
pubkey: string pubkey: string
reason: string reason: string
@@ -865,41 +839,6 @@ export const deriveSpaceBannedPubkeyItems = (url: string) => {
return store return store
} }
export const deriveRoomMembers = (url: string, h: string) => {
const filters: Filter[] = [
{kinds: [ROOM_MEMBERS], "#d": [h]},
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
]
return derived(deriveEventsForUrl(url, filters), $events => {
const membersEvent = find(spec({kind: ROOM_MEMBERS}), $events)
if (membersEvent) {
return uniq(getPubkeyTagValues(membersEvent.tags))
}
const members = new Set<string>()
for (const event of sortEventsAsc($events)) {
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === ROOM_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
})
}
export const deriveRoomAdmins = (url: string, h: string) => { export const deriveRoomAdmins = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}] const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}]
@@ -914,6 +853,42 @@ export const deriveRoomAdmins = (url: string, h: string) => {
}) })
} }
const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
const members = new Set<string>()
for (const event of sortEventsAsc(events)) {
if (event.kind === ROOM_MEMBERS && getTagValue("d", event.tags) === h) {
members.clear()
for (const pubkey of uniq(getPubkeyTagValues(event.tags))) {
members.add(pubkey)
}
continue
}
if (getTagValue("h", event.tags) !== h) {
continue
}
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === ROOM_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
}
// Action items (admin review queue) // Action items (admin review queue)
// const pendingJoins: TrustedEvent[] = [] // const pendingJoins: TrustedEvent[] = []
@@ -921,7 +896,7 @@ export const deriveSpaceActionItems = (url: string) =>
derived( derived(
deriveEventsForUrl(url, [ deriveEventsForUrl(url, [
{ {
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS], kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
}, },
]), ]),
$events => { $events => {
@@ -934,19 +909,50 @@ export const deriveSpaceActionItems = (url: string) =>
for (const [h, roomEvents] of groupBy(getRoomId, $events)) { for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
if (!h) continue if (!h) continue
const roomJoins = roomEvents.filter(spec({kind: ROOM_JOIN})) const roomJoins: TrustedEvent[] = []
const roomLeaves = roomEvents.filter(spec({kind: ROOM_LEAVE})) const roomLeaves: TrustedEvent[] = []
const roomMembersEvent = roomEvents.find(spec({kind: ROOM_MEMBERS})) const roomMembershipEvents: TrustedEvent[] = []
const roomMembers = getTagValues("p", roomMembersEvent?.tags ?? [])
for (const event of roomEvents) {
switch (event.kind) {
case ROOM_JOIN:
roomJoins.push(event)
break
case ROOM_LEAVE:
roomLeaves.push(event)
break
case ROOM_MEMBERS:
case ROOM_ADD_MEMBER:
case ROOM_REMOVE_MEMBER:
roomMembershipEvents.push(event)
break
}
}
const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents))
pendingJoins.push( pendingJoins.push(
...removeUndefined( ...removeUndefined(
Array.from(groupBy(e => e.pubkey, roomJoins).values()) Array.from(groupBy(e => e.pubkey, roomJoins).values()).map(events =>
.map(sortEventsDesc) first(sortEventsDesc(events)),
.map(first), ),
).filter(({pubkey, created_at}) => { ).filter(({pubkey, created_at}) => {
if (roomMembers.includes(pubkey)) return false if (roomMembers.has(pubkey)) return false
if (gt(roomMembersEvent?.created_at, created_at)) return false if (
roomMembershipEvents.some(event => {
if (event.created_at <= created_at) {
return false
}
if (event.kind === ROOM_MEMBERS) {
return true
}
return getPubkeyTagValues(event.tags).includes(pubkey)
})
) {
return false
}
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
return true return true
@@ -979,19 +985,49 @@ export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
}) })
export const deriveUserSpaceMembershipStatus = (url: string) => { export const deriveUserSpaceMembershipStatus = (url: string) => {
const filters: Filter[] = [{kinds: [RELAY_JOIN, RELAY_LEAVE]}] // Fetch member list and user add/remove events directly in this derivation.
const memberListFilters: Filter[] = [{kinds: [RELAY_MEMBERS]}]
const userEventFilters: Filter[] = [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}]
return derived( return derived(
[ [
pubkey, pubkey,
deriveSpaceMembers(url), deriveRelaySignedEvents(url, memberListFilters),
deriveEventsForUrl(url, filters), deriveRelaySignedEvents(url, userEventFilters),
deriveEventsForUrl(url, [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]),
deriveUserIsSpaceAdmin(url), deriveUserIsSpaceAdmin(url),
], ],
([$pubkey, $members, $events, $isAdmin]) => { ([$pubkey, $memberListEvents, $userAddRemoveEvents, $joinLeaveEvents, $isAdmin]) => {
const isMember = $members.includes($pubkey!) || $isAdmin // If admin, always granted.
if ($isAdmin) {
return MembershipStatus.Granted
}
for (const event of $events) { const membersEvent = $memberListEvents.find(spec({kind: RELAY_MEMBERS}))
const memberList = membersEvent ? uniq(getTagValues("member", membersEvent.tags)) : undefined
let isMember = false
if (memberList) {
// Member list exists - check if user is in it.
isMember = memberList.includes($pubkey!)
} else {
// No member list available - replay the user's add/remove history.
for (const event of sortBy(e => e.created_at, $userAddRemoveEvents)) {
if (event.pubkey !== $pubkey) {
continue
}
if (event.kind === RELAY_ADD_MEMBER) {
isMember = true
} else if (event.kind === RELAY_REMOVE_MEMBER) {
isMember = false
}
}
}
for (const event of $joinLeaveEvents) {
// Join events indicate pending or granted status, leave resets to initial.
if (event.pubkey !== $pubkey) { if (event.pubkey !== $pubkey) {
continue continue
} }
@@ -1017,19 +1053,46 @@ export const deriveUserIsRoomAdmin = (url: string, h: string) =>
) )
export const deriveUserRoomMembershipStatus = (url: string, h: string) => { export const deriveUserRoomMembershipStatus = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}] // Fetch the room member list and the current user's add/remove events.
const userEventFilters: Filter[] = [{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}]
const joinLeaveFilters: Filter[] = [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}]
return derived( return derived(
[ [
pubkey, pubkey,
deriveRoomMembers(url, h), deriveRoomMembers(url, h),
deriveEventsForUrl(url, filters), deriveEventsForUrl(url, userEventFilters),
deriveEventsForUrl(url, joinLeaveFilters),
deriveUserIsRoomAdmin(url, h), deriveUserIsRoomAdmin(url, h),
], ],
([$pubkey, $members, $events, $isAdmin]) => { ([$pubkey, $memberList, $userAddRemoveEvents, $joinLeaveEvents, $isAdmin]) => {
const isMember = $members.includes($pubkey!) || $isAdmin // If admin of this room's space, always granted.
if ($isAdmin) {
return MembershipStatus.Granted
}
for (const event of $events) { let isMember = false
if ($memberList) {
// Member list exists - check if user is in it.
isMember = $memberList.includes($pubkey!)
} else {
// No member list available - replay the user's add/remove history.
for (const event of sortEventsAsc($userAddRemoveEvents)) {
if (event.pubkey !== $pubkey) {
continue
}
if (event.kind === ROOM_ADD_MEMBER) {
isMember = true
} else if (event.kind === ROOM_REMOVE_MEMBER) {
isMember = false
}
}
}
for (const event of $joinLeaveEvents) {
// Join events indicate pending or granted status, leave resets to initial.
if (event.pubkey !== $pubkey) { if (event.pubkey !== $pubkey) {
continue continue
} }
+18 -57
View File
@@ -1,7 +1,6 @@
import {page} from "$app/stores" import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store" import type {Unsubscriber} from "svelte/store"
import {last, call, ifLet, assoc, chunk, WEEK, ago} from "@welshman/lib" import {last, call, assoc, chunk, WEEK, ago} from "@welshman/lib"
import {PollResponse} from "nostr-tools/kinds"
import {merged} from "@welshman/store" import {merged} from "@welshman/store"
import { import {
getListTags, getListTags,
@@ -13,13 +12,16 @@ import {
ROOM_MEMBERS, ROOM_MEMBERS,
ROOM_ADD_MEMBER, ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER, ROOM_REMOVE_MEMBER,
ROOM_JOIN,
ROOM_LEAVE,
ROOM_CREATE_PERMISSION, ROOM_CREATE_PERMISSION,
RELAY_MEMBERS, RELAY_MEMBERS,
RELAY_ADD_MEMBER, RELAY_ADD_MEMBER,
RELAY_REMOVE_MEMBER, RELAY_REMOVE_MEMBER,
MESSAGE,
POLL_RESPONSE,
isSignedEvent, isSignedEvent,
unionFilters, unionFilters,
getTagValue,
} from "@welshman/util" } from "@welshman/util"
import type {Filter, List, PublishedList, TrustedEvent} from "@welshman/util" import type {Filter, List, PublishedList, TrustedEvent} from "@welshman/util"
import {request, requestOne, Difference, DifferenceEvent} from "@welshman/net" import {request, requestOne, Difference, DifferenceEvent} from "@welshman/net"
@@ -41,7 +43,6 @@ import {
} from "@welshman/app" } from "@welshman/app"
import { import {
REACTION_KINDS, REACTION_KINDS,
MESSAGE_KINDS,
CONTENT_KINDS, CONTENT_KINDS,
INDEXER_RELAYS, INDEXER_RELAYS,
loadSettings, loadSettings,
@@ -55,7 +56,7 @@ import {
loadFeedsForPubkey, loadFeedsForPubkey,
} from "@app/core/state" } from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands" import {hasBlossomSupport} from "@app/core/commands"
import {LIVEKIT_PARTICIPANTS} from "@app/voice" import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
// Utils // Utils
@@ -266,53 +267,21 @@ const syncUserData = () => {
// Spaces // Spaces
const syncSpace = (url: string, rooms: string[]) => { const syncSpace = (url: string) => {
const since = ago(WEEK) const since = ago(WEEK)
const seen = new Set<string>()
const controller = new AbortController() const controller = new AbortController()
const relayKinds = [RELAY_MEMBERS]
const pullRoomContent = (room: string) => {
if (!seen.has(room)) {
seen.add(room)
pullAndListen({
url,
signal: controller.signal,
filters: [
{kinds: MESSAGE_KINDS, since, "#h": [room]},
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
{kinds: [PollResponse], since},
],
})
}
}
for (const room of rooms) {
pullRoomContent(room)
}
const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS] const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
const roomMemberKinds = [ROOM_DELETE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER] const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE]
pullAndListen({ pullAndListen({
url, url,
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]}, {kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
makeCommentFilter(CONTENT_KINDS, {since}), makeCommentFilter(CONTENT_KINDS, {since}),
{kinds: [PollResponse], since}, {kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
], ],
onEvent: event => {
if (event.kind === ROOM_META) {
ifLet(getTagValue("d", event.tags), pullRoomContent)
}
},
})
listen({
url,
signal: controller.signal,
filters: [{kinds: REACTION_KINDS}, {kinds: [PollResponse]}],
}) })
return () => controller.abort() return () => controller.abort()
@@ -321,36 +290,28 @@ const syncSpace = (url: string, rooms: string[]) => {
const syncSpaces = () => { const syncSpaces = () => {
const store = merged([userGroupList, page]) const store = merged([userGroupList, page])
const unsubscribersByUrl = new Map<string, Unsubscriber>() const unsubscribersByUrl = new Map<string, Unsubscriber>()
const roomsByUrl = new Map<string, string>()
const unsubscribe = store.subscribe(([$userGroupList, $page]) => { const unsubscribe = store.subscribe(([$userGroupList, $page]) => {
const urls = new Set(getSpaceUrlsFromGroupList($userGroupList)) const urls = new Set(getSpaceUrlsFromGroupList($userGroupList))
const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined
if ($page.params.relay) { if (currentUrl) {
urls.add(decodeRelay($page.params.relay)) urls.add(currentUrl)
} }
// Stop syncing removed spaces // Stop syncing removed spaces
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) { for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!urls.has(url)) { if (!urls.has(url)) {
unsubscribersByUrl.delete(url) unsubscribersByUrl.delete(url)
roomsByUrl.delete(url)
unsubscribe() unsubscribe()
} }
} }
// Start or restart syncing for each space // Start syncing for new spaces
for (const url of urls) { for (const url of urls) {
const rooms = getSpaceRoomsFromGroupList(url, $userGroupList) if (!unsubscribersByUrl.has(url)) {
const roomsKey = rooms.join(",") unsubscribersByUrl.set(url, syncSpace(url))
}
if (unsubscribersByUrl.has(url) && roomsByUrl.get(url) === roomsKey) continue
// Tear down existing sync if rooms changed
unsubscribersByUrl.get(url)?.()
roomsByUrl.set(url, roomsKey)
unsubscribersByUrl.set(url, syncSpace(url, rooms))
} }
}) })
+42
View File
@@ -0,0 +1,42 @@
import {mergeAttributes, Node} from "@tiptap/core"
import {RoomReferenceNodeView} from "@app/editor/RoomReferenceNodeView"
export const RoomReferenceExtension = Node.create({
name: "roomref",
atom: true,
inline: true,
group: "inline",
selectable: true,
priority: 1000,
addAttributes() {
return {
url: {default: undefined},
h: {default: undefined},
}
},
parseHTML() {
return [{tag: `span[data-type="${this.name}"]`}]
},
renderHTML({HTMLAttributes}) {
return ["span", mergeAttributes(HTMLAttributes, {"data-type": this.name}), "~"]
},
renderText({node}) {
const url = typeof node.attrs.url === "string" ? node.attrs.url : ""
const h = typeof node.attrs.h === "string" ? node.attrs.h : ""
return `${url}'${h}`
},
addNodeView() {
return RoomReferenceNodeView
},
})
+29
View File
@@ -0,0 +1,29 @@
import type {NodeViewRendererProps} from "@tiptap/core"
import {displayRelayUrl} from "@welshman/util"
import {deriveRoom} from "@app/core/state"
export const RoomReferenceNodeView = ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
const url = typeof node.attrs.url === "string" ? node.attrs.url : ""
const h = typeof node.attrs.h === "string" ? node.attrs.h : ""
const room = deriveRoom(url, h)
dom.classList.add("tiptap-object")
const unsubRoom = room.subscribe($room => {
dom.textContent = `~${displayRelayUrl(url)} / ${$room.name || h}`
})
return {
dom,
destroy: () => {
unsubRoom()
},
selectNode() {
dom.classList.add("tiptap-active")
},
deselectNode() {
dom.classList.remove("tiptap-active")
},
}
}
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {splitRoomId} from "@app/core/state"
type Props = {
value: string
}
const {value}: Props = $props()
const [url = "", h = ""] = splitRoomId(value)
</script>
<div class="max-w-full overflow-hidden text-ellipsis flex flex-col">
<RoomNameWithImage {url} {h} />
<span class="text-primary text-sm">{displayRelayUrl(url)}<span> </span></span>
</div>
+81
View File
@@ -0,0 +1,81 @@
import {Clipboard} from "@capacitor/clipboard"
import {Capacitor} from "@capacitor/core"
import {Extension} from "@tiptap/core"
import {Plugin, PluginKey} from "@tiptap/pm/state"
const nativeClipboardAvailable = () =>
Capacitor.isNativePlatform() && Capacitor.isPluginAvailable("Clipboard")
const hasStandardPastePayload = (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
if (!clipboardData) {
return false
}
if (Array.from(clipboardData.items).some(item => item.kind === "file")) {
return true
}
if (clipboardData.types.includes("text/html")) {
return true
}
return clipboardData.getData("text/plain") !== ""
}
const getNativeClipboardImage = async () => {
try {
const {type, value} = await Clipboard.read()
if (!type.startsWith("image/") || value === "") {
return undefined
}
const imageData = value.startsWith("data:") ? value : `data:${type};base64,${value}`
const blob = await fetch(imageData).then(res => res.blob())
if (!blob.type.startsWith("image/")) {
return undefined
}
const extension = type.split("/")[1]?.split("+")[0] || "png"
return new File([blob], `clipboard-image.${extension}`, {type: blob.type || type})
} catch {
return undefined
}
}
export const NativeClipboardPasteExtension = Extension.create({
name: "nativeClipboardPaste",
addProseMirrorPlugins() {
const editor = this.editor
return [
new Plugin({
key: new PluginKey("nativeClipboardPaste"),
props: {
handlePaste: (_view, event) => {
if (!nativeClipboardAvailable() || hasStandardPastePayload(event)) {
return false
}
event.preventDefault()
void getNativeClipboardImage().then(file => {
if (!file) {
return
}
editor.commands.addFile(file, editor.state.selection.from + 1)
})
return true
},
},
}),
]
},
})
+68 -4
View File
@@ -4,7 +4,7 @@ import {get, derived} from "svelte/store"
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
import {dec, inc} from "@welshman/lib" import {dec, inc} from "@welshman/lib"
import {throttled} from "@welshman/store" import {throttled} from "@welshman/store"
import type {PublishedProfile} from "@welshman/util" import type {PublishedProfile, RoomMeta} from "@welshman/util"
import { import {
createSearch, createSearch,
profiles, profiles,
@@ -14,12 +14,27 @@ import {
getWotGraph, getWotGraph,
} from "@welshman/app" } from "@welshman/app"
import type {FileAttributes} from "@welshman/editor" import type {FileAttributes} from "@welshman/editor"
import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor" import {
Editor,
MentionSuggestion,
TippySuggestion,
WelshmanExtension,
editorProps,
} from "@welshman/editor"
import {escapeHtml} from "@lib/html" import {escapeHtml} from "@lib/html"
import {makeMentionNodeView} from "@app/editor/MentionNodeView" import {makeMentionNodeView} from "@app/editor/MentionNodeView"
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte" import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
import RoomSuggestion from "@app/editor/RoomSuggestion.svelte"
import {NativeClipboardPasteExtension} from "@app/editor/clipboard"
import {uploadFile} from "@app/core/commands" import {uploadFile} from "@app/core/commands"
import {deriveSpaceMembers} from "@app/core/state" import {
deriveSpaceMembers,
makeRoomId,
splitRoomId,
userSpaceUrls,
roomsByUrl,
} from "@app/core/state"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
export const makeEditor = async ({ export const makeEditor = async ({
@@ -64,7 +79,7 @@ export const makeEditor = async ({
getValue: (profile: PublishedProfile) => profile.event.pubkey, getValue: (profile: PublishedProfile) => profile.event.pubkey,
sortFn: ({score = 1, item}) => { sortFn: ({score = 1, item}) => {
const wotScore = getWotGraph().get(item.event.pubkey) || 0 const wotScore = getWotGraph().get(item.event.pubkey) || 0
const membershipScale = $spaceMembers.includes(item.event.pubkey) ? 2 : 1 const membershipScale = $spaceMembers?.includes(item.event.pubkey) ? 2 : 1
return dec(score) * inc(wotScore / getMaxWot()) * membershipScale return dec(score) * inc(wotScore / getMaxWot()) * membershipScale
}, },
@@ -82,11 +97,36 @@ export const makeEditor = async ({
}, },
) )
const roomReferenceSearch = derived(
[throttled(800, userSpaceUrls), throttled(800, roomsByUrl)],
([$userSpaceUrls, $roomsByUrl]) => {
const roomIdByMeta = new WeakMap<RoomMeta, string>()
const options: RoomMeta[] = []
for (const roomUrl of $userSpaceUrls) {
for (const room of $roomsByUrl.get(roomUrl) || []) {
roomIdByMeta.set(room, makeRoomId(roomUrl, room.h))
options.push(room)
}
}
return createSearch(options, {
getValue: item => roomIdByMeta.get(item) || item.h,
fuseOptions: {
keys: ["name", "h"],
threshold: 0.3,
shouldSort: false,
},
})
},
)
const ed = new Editor({ const ed = new Editor({
content: typeof content === "string" ? escapeHtml(content) : content, content: typeof content === "string" ? escapeHtml(content) : content,
editorProps, editorProps,
element: document.createElement("div"), element: document.createElement("div"),
extensions: [ extensions: [
RoomReferenceExtension,
WelshmanExtension.configure({ WelshmanExtension.configure({
submit, submit,
extensions: { extensions: {
@@ -128,6 +168,29 @@ export const makeEditor = async ({
mount(ProfileSuggestion, {target, props: {value, url}}) mount(ProfileSuggestion, {target, props: {value, url}})
return target
},
}),
TippySuggestion({
char: "~",
name: "roomref",
editor: (this as any).editor,
search: (term: string) => get(roomReferenceSearch).searchValues(term),
updateSignal: roomReferenceSearch,
select: (id: string, props) => {
const [roomUrl, h] = splitRoomId(id)
if (!roomUrl || !h) {
return
}
return props.command({url: roomUrl, h})
},
createSuggestion: (value: string) => {
const target = document.createElement("div")
mount(RoomSuggestion, {target, props: {value}})
return target return target
}, },
}), }),
@@ -137,6 +200,7 @@ export const makeEditor = async ({
}, },
}, },
}), }),
NativeClipboardPasteExtension,
], ],
onUpdate({editor}) { onUpdate({editor}) {
wordCount?.set(editor.storage.wordCount.words) wordCount?.set(editor.storage.wordCount.words)
+33 -1
View File
@@ -1,4 +1,4 @@
import {writable} from "svelte/store" import {get, writable} from "svelte/store"
import type {Nip46ResponseWithResult} from "@welshman/signer" import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker} from "@welshman/signer" import {Nip46Broker} from "@welshman/signer"
import {makeSecret} from "@welshman/util" import {makeSecret} from "@welshman/util"
@@ -11,6 +11,22 @@ import {
} from "@app/core/state" } from "@app/core/state"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
const APP_SCHEME = "social.flotilla"
const makeSignerCallbackUrl = (path: string) => `${APP_SCHEME}://x-callback-url/${path}`
const makeSignerLaunchUrl = (nostrconnectUrl: string) => {
const params = new URLSearchParams({
method: "connect",
nostrconnect: nostrconnectUrl,
"x-source": APP_SCHEME,
"x-success": makeSignerCallbackUrl("authSuccess"),
"x-error": makeSignerCallbackUrl("authError"),
})
return `nostrsigner://x-callback-url/auth/nip46?${params.toString()}`
}
export class Nip46Controller { export class Nip46Controller {
url = writable("") url = writable("")
bunker = writable("") bunker = writable("")
@@ -54,6 +70,22 @@ export class Nip46Controller {
} }
} }
launchSigner() {
const nostrconnectUrl = get(this.url)
const signerUrl = nostrconnectUrl && makeSignerLaunchUrl(nostrconnectUrl)
if (!signerUrl) {
pushToast({
theme: "error",
message: "Unable to open signer app right now. Please try again.",
})
return
}
window.location.href = signerUrl
}
stop() { stop() {
this.broker.cleanup() this.broker.cleanup()
this.abortController.abort() this.abortController.abort()
+3 -3
View File
@@ -5,10 +5,10 @@ import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
import {assoc, prop, first, identity, groupBy, now} from "@welshman/lib" import {assoc, prop, first, identity, groupBy, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {deriveEventsByIdByUrl} from "@welshman/store" import {deriveEventsByIdByUrl} from "@welshman/store"
import {sortEventsDesc, getTagValue} from "@welshman/util" import {sortEventsDesc, getTagValue, MESSAGE} from "@welshman/util"
import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/util/routes" import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/util/routes"
import { import {
MESSAGE_KINDS, CONTENT_KINDS,
notificationSettings, notificationSettings,
chatsById, chatsById,
userGroupList, userGroupList,
@@ -85,7 +85,7 @@ export const allNotifications = derived(
deriveEventsByIdByUrl({ deriveEventsByIdByUrl({
tracker, tracker,
repository, repository,
filters: [{kinds: MESSAGE_KINDS}, makeCommentFilter(MESSAGE_KINDS)], filters: [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)],
}), }),
], ],
identity, identity,
+6 -3
View File
@@ -17,13 +17,13 @@ import {
getRelaysFromList, getRelaysFromList,
getTagValue, getTagValue,
matchFilters, matchFilters,
MESSAGE,
type Filter, type Filter,
type TrustedEvent, type TrustedEvent,
} from "@welshman/util" } from "@welshman/util"
import { import {
DM_KINDS, DM_KINDS,
CONTENT_KINDS, CONTENT_KINDS,
MESSAGE_KINDS,
notificationSettings, notificationSettings,
pushState, pushState,
shouldNotify, shouldNotify,
@@ -45,7 +45,10 @@ export type PushPermissionResult = {
} }
export const onNotification = call(() => { export const onNotification = call(() => {
const allFilters = [{kinds: [...MESSAGE_KINDS, ...DM_KINDS]}, makeCommentFilter(MESSAGE_KINDS)] const allFilters = [
{kinds: [MESSAGE, ...CONTENT_KINDS, ...DM_KINDS]},
makeCommentFilter(CONTENT_KINDS),
]
const filters = allFilters.map(assoc("since", now())) const filters = allFilters.map(assoc("since", now()))
const subscribers: Subscriber<TrustedEvent>[] = [] const subscribers: Subscriber<TrustedEvent>[] = []
@@ -158,7 +161,7 @@ export const syncRelaySubscriptions = (
userSettingsValues, userSettingsValues,
]).subscribe( ]).subscribe(
throttle(3000, ([$userSpaceUrls, {spaces, mentions}, {alerts}]) => { throttle(3000, ([$userSpaceUrls, {spaces, mentions}, {alerts}]) => {
const baseFilters = [{kinds: MESSAGE_KINDS}, makeCommentFilter(CONTENT_KINDS)] const baseFilters = [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)]
for (const url of $userSpaceUrls) { for (const url of $userSpaceUrls) {
const {notify = true, exceptions = []} = alerts.find(spec({url})) || {} const {notify = true, exceptions = []} = alerts.find(spec({url})) || {}
+6 -6
View File
@@ -6,7 +6,6 @@ import {page} from "$app/stores"
import {nthEq} from "@welshman/lib" import {nthEq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util" import {getAddress} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import {tracker, userMessagingRelayList} from "@welshman/app" import {tracker, userMessagingRelayList} from "@welshman/app"
import {identity} from "@welshman/lib" import {identity} from "@welshman/lib"
import { import {
@@ -16,6 +15,7 @@ import {
CLASSIFIED, CLASSIFIED,
ZAP_GOAL, ZAP_GOAL,
EVENT_TIME, EVENT_TIME,
POLL,
getPubkeyTagValues, getPubkeyTagValues,
getRelaysFromList, getRelaysFromList,
} from "@welshman/util" } from "@welshman/util"
@@ -63,11 +63,11 @@ export const goToSpace = async (url: string) => {
const prevPath = lastPageBySpaceUrl.get(encodeRelay(url)) const prevPath = lastPageBySpaceUrl.get(encodeRelay(url))
if (prevPath && prevPath !== makeSpacePath(url)) { if (prevPath && prevPath !== makeSpacePath(url)) {
goto(prevPath) goto(prevPath, {replaceState: true})
} else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) { } else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) {
goto(makeSpacePath(url, "recent")) goto(makeSpacePath(url, "recent"), {replaceState: true})
} else { } else {
goto(makeSpacePath(url)) goto(makeSpacePath(url), {replaceState: true})
} }
} }
@@ -149,7 +149,7 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
return makeCalendarPath(url, getAddress(event)) return makeCalendarPath(url, getAddress(event))
} }
if (event.kind === Poll) { if (event.kind === POLL) {
return makePollPath(url, event.id) return makePollPath(url, event.id)
} }
@@ -199,7 +199,7 @@ export const getRoomItemPath = (url: string, event: TrustedEvent) => {
return makeGoalPath(url, event.id) return makeGoalPath(url, event.id)
case EVENT_TIME: case EVENT_TIME:
return makeCalendarPath(url, getAddress(event)) return makeCalendarPath(url, getAddress(event))
case Poll: case POLL:
return makePollPath(url, event.id) return makePollPath(url, event.id)
} }
} }
+17 -4
View File
@@ -48,6 +48,18 @@ import {
import type {Unsubscriber} from "svelte/store" import type {Unsubscriber} from "svelte/store"
import {db} from "@app/core/storage" import {db} from "@app/core/storage"
// Shared interval for all non-critical store flushes, so they batch on the same cadence
const FLUSH_INTERVAL = 3000
// Wraps a write callback to run during idle time (non-critical persistence)
const idleWrite = <T>(f: (xs: T[]) => void): ((xs: T[]) => void) => {
if (typeof requestIdleCallback !== "undefined") {
return (xs: T[]) => requestIdleCallback(() => f(xs))
}
return f
}
const kinds = { const kinds = {
meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS], meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS],
alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID], alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID],
@@ -199,14 +211,15 @@ const loadCriticalRelays = async () => {
relaysByUrl.set(indexBy(r => r.url, await table.getAll())) relaysByUrl.set(indexBy(r => r.url, await table.getAll()))
} }
const syncRelays = () => onRelay(batch(1000, db.table<RelayProfile>("relays").bulkPut)) const syncRelays = () =>
onRelay(batch(FLUSH_INTERVAL, idleWrite(db.table<RelayProfile>("relays").bulkPut)))
const initRelayStats = async () => { const initRelayStats = async () => {
const table = db.table<RelayStats>("relayStats") const table = db.table<RelayStats>("relayStats")
relayStatsByUrl.set(indexBy(r => r.url, await table.getAll())) relayStatsByUrl.set(indexBy(r => r.url, await table.getAll()))
return onRelayStats(batch(1000, table.bulkPut)) return onRelayStats(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)))
} }
const initHandles = async () => { const initHandles = async () => {
@@ -214,7 +227,7 @@ const initHandles = async () => {
handlesByNip05.set(indexBy(r => r.nip05, await table.getAll())) handlesByNip05.set(indexBy(r => r.nip05, await table.getAll()))
return onHandle(batch(1000, table.bulkPut)) return onHandle(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)))
} }
const initZappers = async () => { const initZappers = async () => {
@@ -222,7 +235,7 @@ const initZappers = async () => {
zappersByLnurl.set(indexBy(z => z.lnurl, await table.getAll())) zappersByLnurl.set(indexBy(z => z.lnurl, await table.getAll()))
return onZapper(batch(3000, table.bulkPut)) return onZapper(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)))
} }
const initPlaintext = async () => { const initPlaintext = async () => {
+8
View File
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="6" r="1.5" fill="#000000"/>
<circle cx="15" cy="6" r="1.5" fill="#000000"/>
<circle cx="9" cy="12" r="1.5" fill="#000000"/>
<circle cx="15" cy="12" r="1.5" fill="#000000"/>
<circle cx="9" cy="18" r="1.5" fill="#000000"/>
<circle cx="15" cy="18" r="1.5" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 400 B

+2 -1
View File
@@ -14,9 +14,10 @@
style?: string style?: string
disabled?: boolean disabled?: boolean
"data-tip"?: string "data-tip"?: string
"aria-pressed"?: boolean
} = $props() } = $props()
const className = $derived(`text-left ${restProps.class}`) const className = $derived(`text-left cursor-pointer ${restProps.class}`)
const onClick = (e: Event) => { const onClick = (e: Event) => {
e.preventDefault() e.preventDefault()
+7 -6
View File
@@ -68,22 +68,23 @@
}) })
</script> </script>
<div class="relative grid grid-cols-2 gap-2" bind:this={element}> <div class="relative focus-within:z-modal grid grid-cols-2 gap-2" bind:this={element}>
<div class="relative"> <div class="relative group z-popover">
<DateInput format="yyyy-MM-dd" placeholder="" bind:value={date} /> <DateInput format="yyyy-MM-dd" placeholder="" bind:value={date} />
<div class="absolute right-2 top-0 flex h-12 cursor-pointer items-center gap-2"> <div
class="absolute right-2 top-0 flex h-12 cursor-pointer items-center gap-2 opacity-100 group-focus-within:opacity-0 group-focus-within:pointer-events-none transition-opacity pointer-events-none">
{#if date} {#if date}
<Button onclick={clear} class="h-5"> <Button onclick={clear} class="h-5 pointer-events-auto">
<Icon icon={CloseCircle} /> <Icon icon={CloseCircle} />
</Button> </Button>
{:else} {:else}
<Button onclick={focusDate} class="h-5"> <Button onclick={focusDate} class="h-5 pointer-events-auto">
<Icon icon={CalendarMinimalistic} /> <Icon icon={CalendarMinimalistic} />
</Button> </Button>
{/if} {/if}
</div> </div>
</div> </div>
<label class="input input-bordered flex items-center"> <label class="input input-bordered flex items-center relative">
<input <input
list="time-options" list="time-options"
class="grow" class="grow"
+3 -4
View File
@@ -6,7 +6,6 @@
import Close from "@assets/icons/close.svg?dataurl" import Close from "@assets/icons/close.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {clearModals} from "@app/util/modal"
type Props = { type Props = {
onClose?: any onClose?: any
@@ -38,9 +37,9 @@
) )
const buttonClass = $derived( const buttonClass = $derived(
cx("absolute right-3 btn btn-circle btn-neutral btn-sm", { cx("absolute right-3 z-tooltip btn btn-circle btn-neutral btn-sm", {
"top-3": fullscreen, "top-3": fullscreen,
"-top-4": !fullscreen, "-top-4 mr-sai": !fullscreen,
}), }),
) )
</script> </script>
@@ -56,7 +55,7 @@
<div class={wrapperClass}> <div class={wrapperClass}>
<div class={innerClass} transition:fly> <div class={innerClass} transition:fly>
{#if !noEscape} {#if !noEscape}
<Button class={buttonClass} onclick={clearModals}> <Button class={buttonClass} onclick={onClose}>
<Icon icon={Close} size={6} /> <Icon icon={Close} size={6} />
</Button> </Button>
{/if} {/if}
+6 -1
View File
@@ -10,7 +10,12 @@
let {children, element = $bindable(), ...props}: Props = $props() let {children, element = $bindable(), ...props}: Props = $props()
const className = cx(props.class, "scroll-container z-feature overflow-y-auto overflow-x-hidden") const className = $derived(
cx(
props.class,
"scroll-container z-feature flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-y-auto overflow-x-hidden",
),
)
</script> </script>
<div {...props} bind:this={element} data-component="PageContent" class={className}> <div {...props} bind:this={element} data-component="PageContent" class={className}>
+4 -2
View File
@@ -4,15 +4,17 @@
interface Props { interface Props {
class?: string class?: string
visible?: boolean
children?: Snippet children?: Snippet
} }
const {children, ...props}: Props = $props() const {children, visible = false, ...props}: Props = $props()
</script> </script>
<div <div
class={cx( class={cx(
"mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav hidden md:flex", "mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav",
visible ? "flex" : "hidden md:flex",
props.class, props.class,
)}> )}>
{@render children?.()} {@render children?.()}
+3 -1
View File
@@ -46,7 +46,9 @@ export const whenAborted = (signal?: AbortSignal) => {
/** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */ /** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */
export const whenTimeout = (ms: number, opts: {message?: string} = {}) => { export const whenTimeout = (ms: number, opts: {message?: string} = {}) => {
return new Promise<never>((_, reject) => setTimeout(() => reject(new TimeoutError()), ms)) return new Promise<never>((_, reject) =>
setTimeout(() => reject(new TimeoutError(opts.message)), ms),
)
} }
export const buildUrl = (base: string | URL, ...pathname: string[]) => { export const buildUrl = (base: string | URL, ...pathname: string[]) => {
+15
View File
@@ -78,6 +78,21 @@
return return
} }
if (url.host === "x-callback-url") {
if (url.pathname === "/authError") {
const errorMessage = url.searchParams.get("errorMessage")
pushToast({
theme: "error",
message: errorMessage || "Signer authorization failed.",
})
}
if (["/authSuccess", "/authError"].includes(url.pathname)) {
return
}
}
const target = `${url.pathname}${url.search}${url.hash}` const target = `${url.pathname}${url.search}${url.hash}`
goto(target, {replaceState: false, noScroll: false}) goto(target, {replaceState: false, noScroll: false})
}) })
+4 -2
View File
@@ -4,13 +4,15 @@
import Dialog from "@lib/components/Dialog.svelte" import Dialog from "@lib/components/Dialog.svelte"
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte" import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
const close = () => goto("/home")
const children = { const children = {
component: SpaceInviteAccept, component: SpaceInviteAccept,
props: { props: {
invite: $page.url.href, invite: $page.url.href,
back: () => goto("/home"), back: close,
}, },
} }
</script> </script>
<Dialog {children} /> <Dialog {children} onClose={close} />
+1 -1
View File
@@ -40,7 +40,7 @@
<Page> <Page>
<ContentSearch> <ContentSearch>
{#snippet input()} {#snippet input()}
<label class="row-2 input input-bordered"> <label class="row-2 input input-bordered w-full">
<Icon icon={Magnifier} /> <Icon icon={Magnifier} />
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<input <input
+2 -2
View File
@@ -172,9 +172,9 @@
</strong> </strong>
<Button onclick={() => (showAdvanced = !showAdvanced)}> <Button onclick={() => (showAdvanced = !showAdvanced)}>
{#if showAdvanced} {#if showAdvanced}
<Icon icon={AltArrowDown} />
{:else}
<Icon icon={AltArrowUp} /> <Icon icon={AltArrowUp} />
{:else}
<Icon icon={AltArrowDown} />
{/if} {/if}
</Button> </Button>
</div> </div>
+2 -2
View File
@@ -58,14 +58,14 @@
<RelaySettingsItem <RelaySettingsItem
icon={Inbox} icon={Inbox}
title="Inbox Relays" title="Inbox Relays"
subtitle="Where you send your public notes. Be sure to select relays that will accept your notes, and which will let people who follow you read them." subtitle="Where other people should send notes intended for you. Be sure to select relays that will accept notes that tag you."
relays={readRelayUrls} relays={readRelayUrls}
addRelay={addReadRelay} addRelay={addReadRelay}
removeRelay={removeReadRelay} /> removeRelay={removeReadRelay} />
<RelaySettingsItem <RelaySettingsItem
icon={Plane} icon={Plane}
title="Outbox Relays" title="Outbox Relays"
subtitle="Where other people should send notes intended for you. Be sure to select relays that will accept notes that tag you." subtitle="Where you send your public notes. Be sure to select relays that will accept your notes, and which will let people who follow you read them."
relays={writeRelayUrls} relays={writeRelayUrls}
addRelay={addWriteRelay} addRelay={addWriteRelay}
removeRelay={removeWriteRelay} /> removeRelay={removeWriteRelay} />
+36 -5
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount, tick} from "svelte" import {onMount, tick} from "svelte"
import {flip} from "svelte/animate"
import {cubicOut} from "svelte/easing"
import {derived as _derived} from "svelte/store" import {derived as _derived} from "svelte/store"
import {dec, insertAt, removeAt, sleep} from "@welshman/lib" import {dec, insertAt, removeAt, sleep} from "@welshman/lib"
import type {RelayProfile} from "@welshman/util" import type {RelayProfile} from "@welshman/util"
@@ -7,6 +9,7 @@
import {relays, createSearch} from "@welshman/app" import {relays, createSearch} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import DragHandle from "@assets/icons/drag-handle.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl" import Widget from "@assets/icons/widget-4.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -98,6 +101,8 @@
const onDragStart = (e: DragEvent, url: string) => { const onDragStart = (e: DragEvent, url: string) => {
draggedUrl = url draggedUrl = url
dragStartOrder = [...orderedSpaceUrls] dragStartOrder = [...orderedSpaceUrls]
lastDragTarget = undefined
didDrop = false
if (e.dataTransfer) { if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move" e.dataTransfer.effectAllowed = "move"
@@ -105,15 +110,25 @@
} }
} }
const onDragOver = (e: DragEvent, targetUrl: string) => { const onDragOver = (e: DragEvent) => {
e.preventDefault() e.preventDefault()
}
const onDragEnter = (e: DragEvent, targetUrl: string) => {
e.preventDefault()
if (lastDragTarget === targetUrl) return
lastDragTarget = targetUrl
reorderSpaceUrls(targetUrl) reorderSpaceUrls(targetUrl)
} }
const onDrop = (e: DragEvent, targetUrl: string) => { const onDrop = (e: DragEvent, targetUrl: string) => {
e.preventDefault() e.preventDefault()
reorderSpaceUrls(targetUrl) reorderSpaceUrls(targetUrl)
didDrop = true
draggedUrl = undefined draggedUrl = undefined
lastDragTarget = undefined
if (dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) { if (dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) {
void setSpaceMembershipOrder(orderedSpaceUrls).catch(console.error) void setSpaceMembershipOrder(orderedSpaceUrls).catch(console.error)
@@ -123,8 +138,14 @@
} }
const onDragEnd = () => { const onDragEnd = () => {
if (!didDrop && dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) {
orderedSpaceUrls = dragStartOrder
}
draggedUrl = undefined draggedUrl = undefined
dragStartOrder = undefined dragStartOrder = undefined
lastDragTarget = undefined
didDrop = false
} }
$effect(() => { $effect(() => {
@@ -143,6 +164,8 @@
let orderedSpaceUrls = $state<string[]>([]) let orderedSpaceUrls = $state<string[]>([])
let draggedUrl = $state<string | undefined>() let draggedUrl = $state<string | undefined>()
let dragStartOrder = $state<string[] | undefined>() let dragStartOrder = $state<string[] | undefined>()
let lastDragTarget = $state<string | undefined>()
let didDrop = $state(false)
const openSearch = () => { const openSearch = () => {
showSearch = true showSearch = true
@@ -247,17 +270,25 @@
<Divider>Your spaces</Divider> <Divider>Your spaces</Divider>
{#each filteredUserUrls as url (url)} {#each filteredUserUrls as url (url)}
<div <div
class:opacity-60={draggedUrl === url} animate:flip={{duration: 300, easing: cubicOut}}
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
draggable="true" draggable="true"
role="listitem" role="listitem"
ondragstart={e => onDragStart(e, url)} ondragstart={e => onDragStart(e, url)}
ondragover={e => onDragOver(e, url)} ondragover={onDragOver}
ondragenter={e => onDragEnter(e, url)}
ondrop={e => onDrop(e, url)} ondrop={e => onDrop(e, url)}
ondragend={onDragEnd}> ondragend={onDragEnd}>
<Button <Button
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1] w-full relative" class="group card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1] w-full relative min-w-0"
onclick={() => openSpace(url)}> onclick={() => openSpace(url)}>
<RelaySummary hideFavorites {url} /> <div class="flex w-full items-start gap-2">
<div
class="mt-4 flex cursor-grab p-1 text-base-content/30 transition-colors group-hover:text-base-content/60">
<Icon icon={DragHandle} />
</div>
<RelaySummary hideFavorites {url} />
</div>
{#if $notifications.has(makeSpacePath(url))} {#if $notifications.has(makeSpacePath(url))}
<div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div> <div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div>
{/if} {/if}
+2 -2
View File
@@ -8,7 +8,7 @@
import SpaceMenu from "@app/components/SpaceMenu.svelte" import SpaceMenu from "@app/components/SpaceMenu.svelte"
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
const md = parseInt(theme.screens.md, 10) const md = parseFloat(theme.screens.md) * 16
let width = $state(0) let width = $state(0)
@@ -25,7 +25,7 @@
<div class="ml-sai mt-sai mb-sai relative z-nav w-14 shrink-0 bg-base-200 pt-2"> <div class="ml-sai mt-sai mb-sai relative z-nav w-14 shrink-0 bg-base-200 pt-2">
<PrimaryNavSpaces /> <PrimaryNavSpaces />
</div> </div>
<SecondaryNav class="flex! w-auto! grow pb-16"> <SecondaryNav visible class="w-auto grow pb-16">
<SpaceMenu {url} /> <SpaceMenu {url} />
</SecondaryNav> </SecondaryNav>
{/if} {/if}
+9 -2
View File
@@ -1,7 +1,14 @@
<script> <script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
type Props = {
children?: Snippet
}
const {children}: Props = $props()
</script> </script>
{#key $page.url.searchParams.get("at")} {#key $page.url.searchParams.get("at")}
<slot /> {@render children?.()}
{/key} {/key}
+212 -122
View File
@@ -13,6 +13,7 @@
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl" import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl" import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl" import Login2 from "@assets/icons/login-3.svg?dataurl"
import cx from "classnames"
import {slide, fade, fly} from "@lib/transition" import {slide, fade, fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
@@ -36,14 +37,15 @@
deriveRoom, deriveRoom,
deriveUserRoomMembershipStatus, deriveUserRoomMembershipStatus,
getRoomType, getRoomType,
MESSAGE_KINDS,
MembershipStatus, MembershipStatus,
PROTECTED, PROTECTED,
RoomType, RoomType,
userSettingsValues, userSettingsValues,
} from "@app/core/state" } from "@app/core/state"
import VoiceWidget from "@app/components/VoiceWidget.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {VoiceState, voiceState} from "@app/voice" import VideoCallContent from "@app/components/VideoCallContent.svelte"
import {VoiceState, currentVoiceRoom, voiceState} from "@app/call/stores"
import {VideoCallLayout, videoCallLayout, videoTileCount} from "@app/call/video"
import {makeFeed} from "@app/core/requests" import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications" import {checked} from "@app/util/notifications"
@@ -56,6 +58,49 @@
const url = decodeRelay(relay) const url = decodeRelay(relay)
const room = deriveRoom(url, h) const room = deriveRoom(url, h)
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice) const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
const voiceConnectedHere = $derived(
isVoiceRoom &&
$voiceState === VoiceState.Connected &&
$currentVoiceRoom?.url === url &&
$currentVoiceRoom?.h === h,
)
const showMobileVideoPanel = $derived(
isVoiceRoom &&
$voiceState === VoiceState.Connected &&
$videoCallLayout === VideoCallLayout.Video,
)
const pageContentHiddenDesktopVideoOnly = $derived(
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
)
let prevVideoTileCount = $state(0)
$effect(() => {
if ($voiceState !== VoiceState.Connected) {
videoCallLayout.set(VideoCallLayout.Chat)
prevVideoTileCount = 0
return
}
const here = isVoiceRoom && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h
const n = $videoTileCount
if (!here) {
prevVideoTileCount = 0
return
}
if (prevVideoTileCount === 0 && n >= 1) {
videoCallLayout.set(VideoCallLayout.Video)
}
if (prevVideoTileCount >= 1 && n === 0 && $videoCallLayout === VideoCallLayout.Split) {
videoCallLayout.set(VideoCallLayout.Chat)
}
prevVideoTileCount = n
})
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserRoomMembershipStatus(url, h) const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const at = $derived(parseInt($page.url.searchParams.get("at")!)) const at = $derived(parseInt($page.url.searchParams.get("at")!))
@@ -302,11 +347,15 @@
elements.reverse() elements.reverse()
requestAnimationFrame(manageScrollPosition)
return elements return elements
}) })
$effect(() => {
if (elements.length > 0) {
requestAnimationFrame(manageScrollPosition)
}
})
const start = () => { const start = () => {
cleanup?.() cleanup?.()
@@ -314,7 +363,7 @@
url, url,
at: at || now(), at: at || now(),
element: element!, element: element!,
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER], "#h": [h]}], filters: [{kinds: [MESSAGE, ROOM_ADD_MEMBER], "#h": [h]}],
onBackwardExhausted: () => { onBackwardExhausted: () => {
loadingBackward = false loadingBackward = false
}, },
@@ -364,127 +413,168 @@
{/snippet} {/snippet}
</SpaceBar> </SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4"> <div
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted} class={cx(
<div class="py-20"> "flex min-h-0 flex-1 flex-col",
<div class="card2 col-8 m-auto max-w-md items-center text-center"> voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split && "md:flex-row",
<p class="opacity-75">You aren't currently a member of this room.</p> )}>
{#if $membershipStatus === MembershipStatus.Pending} {#if voiceConnectedHere}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}> <VideoCallContent
<Icon icon={ClockCircle} /> layout={$videoCallLayout}
Access Pending {url}
</Button> {h}
{:else} class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Join Room
</Button>
{/if}
</div>
</div>
{:else}
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey, addSpaceBelow } (id)}
{#if type === "new-messages"}
<div
{id}
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
<div class="h-px grow bg-primary"></div>
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
<div class="h-px grow bg-primary"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = $state.snapshot(value as TrustedEvent)}
{#if event.kind === ROOM_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<div in:slide class="cv">
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
{addSpaceBelow}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
{/if} {/if}
<div class="h-screen"></div>
</PageContent>
<div class="chat__compose flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0"> <div
<div class="chat__compose-inner min-w-0 flex-1"> class={cx(
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted} "flex min-h-0 min-w-0 flex-1 flex-col",
<!-- pass --> voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted} )}>
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3"> {#if isVoiceRoom && $voiceState === VoiceState.Connected}
<p class="opacity-75">Only members are allowed to post to this room.</p> <VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{/if}
</div>
{:else}
<div>
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
initialValues={eventToEdit}
bind:this={compose} />
{/key}
{/if} {/if}
</div>
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected} <PageContent
<div class="hide-on-keyboard shrink-0 p-2 md:hidden"> bind:element
<VoiceWidget /> onscroll={onScroll}
class={cx(
showMobileVideoPanel
? "hidden flex-col-reverse pt-4 md:flex md:flex-col-reverse"
: "flex flex-col-reverse pt-4",
pageContentHiddenDesktopVideoOnly && "md:hidden",
)}>
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<div class="py-20">
<div class="card2 col-8 m-auto max-w-md items-center text-center">
<p class="opacity-75">You aren't currently a member of this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Join Room
</Button>
{/if}
</div>
</div>
{:else}
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey, addSpaceBelow }, i (id)}
{#if type === "new-messages"}
<div
{id}
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
<div class="h-px flex-grow bg-primary"></div>
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
<div class="h-px flex-grow bg-primary"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = value as TrustedEvent}
{#if event.kind === ROOM_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<div in:slide class="cv">
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
{addSpaceBelow}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
{/if}
<div class="h-screen"></div>
</PageContent>
<div
class={cx(
"chat__compose-zone chat__compose flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0",
pageContentHiddenDesktopVideoOnly && "md:hidden",
showMobileVideoPanel && "max-md:hidden",
)}>
<div class="chat__compose-inner min-w-0 flex-1">
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted}
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
<p class="opacity-75">Only members are allowed to post to this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{/if}
</div>
{:else}
<div>
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
initialValues={eventToEdit}
bind:this={compose} />
{/key}
{/if}
</div>
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
<div
class={cx(
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
showMobileVideoPanel && "hidden",
)}>
<VoiceWidget />
</div>
{/if}
</div> </div>
{/if} </div>
</div> </div>
{#if showScrollButton} {#if showScrollButton}
+10 -6
View File
@@ -25,7 +25,7 @@
import RoomCompose from "@app/components/RoomCompose.svelte" import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte" import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte" import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import {userSettingsValues, decodeRelay, PROTECTED, MESSAGE_KINDS} from "@app/core/state" import {userSettingsValues, decodeRelay, PROTECTED} from "@app/core/state"
import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands" import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands"
import {checked} from "@app/util/notifications" import {checked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -240,11 +240,15 @@
elements.reverse() elements.reverse()
requestAnimationFrame(manageScrollPosition)
return elements return elements
}) })
$effect(() => {
if (elements.length > 0) {
requestAnimationFrame(manageScrollPosition)
}
})
const start = () => { const start = () => {
cleanup?.() cleanup?.()
@@ -252,7 +256,7 @@
url, url,
at: at || now(), at: at || now(),
element: element!, element: element!,
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER]}], filters: [{kinds: [MESSAGE, RELAY_ADD_MEMBER]}],
onBackwardExhausted: () => { onBackwardExhausted: () => {
loadingBackward = false loadingBackward = false
}, },
@@ -305,7 +309,7 @@
<Spinner loading={loadingForward}>Looking for messages...</Spinner> <Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p> </p>
{/if} {/if}
{#each elements as { type, id, value, showPubkey, addSpaceBelow } (id)} {#each elements as { type, id, value, showPubkey, addSpaceBelow }, i (id)}
{#if type === "new-messages"} {#if type === "new-messages"}
<div <div
{id} {id}
@@ -318,7 +322,7 @@
{:else if type === "date"} {:else if type === "date"}
<Divider>{value}</Divider> <Divider>{value}</Divider>
{:else} {:else}
{@const event = $state.snapshot(value as TrustedEvent)} {@const event = value as TrustedEvent}
{#if event.kind === RELAY_ADD_MEMBER} {#if event.kind === RELAY_ADD_MEMBER}
<RoomItemAddMember {url} {event} /> <RoomItemAddMember {url} {event} />
{:else} {:else}
+3 -4
View File
@@ -5,7 +5,7 @@
import {page} from "$app/stores" import {page} from "$app/stores"
import {sortBy, partition, spec, pushToMapKey, max} from "@welshman/lib" import {sortBy, partition, spec, pushToMapKey, max} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util" import {getTagValue, POLL} from "@welshman/util"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import PollIcon from "@assets/icons/revote.svg?dataurl" import PollIcon from "@assets/icons/revote.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl" import Add from "@assets/icons/add.svg?dataurl"
@@ -16,7 +16,6 @@
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import PollItem from "@app/components/PollItem.svelte" import PollItem from "@app/components/PollItem.svelte"
import PollCreate from "@app/components/PollCreate.svelte" import PollCreate from "@app/components/PollCreate.svelte"
import {Poll} from "nostr-tools/kinds"
import {decodeRelay, makeCommentFilter} from "@app/core/state" import {decodeRelay, makeCommentFilter} from "@app/core/state"
import {makeFeed} from "@app/core/requests" import {makeFeed} from "@app/core/requests"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -31,7 +30,7 @@
const items = $derived.by(() => { const items = $derived.by(() => {
const scores = new Map<string, number[]>() const scores = new Map<string, number[]>()
const [polls, comments] = partition(spec({kind: Poll}), $events) const [polls, comments] = partition(spec({kind: POLL}), $events)
for (const comment of comments) { for (const comment of comments) {
const id = getTagValue("E", comment.tags) const id = getTagValue("E", comment.tags)
@@ -48,7 +47,7 @@
const feed = makeFeed({ const feed = makeFeed({
url, url,
element: element!, element: element!,
filters: [{kinds: [Poll]}, makeCommentFilter([Poll])], filters: [{kinds: [POLL]}, makeCommentFilter([POLL])],
onBackwardExhausted: () => { onBackwardExhausted: () => {
loading = false loading = false
}, },
@@ -3,7 +3,7 @@
import {page} from "$app/stores" import {page} from "$app/stores"
import {sleep} from "@welshman/lib" import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib"
import {COMMENT} from "@welshman/util" import {COMMENT, POLL, POLL_RESPONSE} from "@welshman/util"
import {repository} from "@welshman/app" import {repository} from "@welshman/app"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import {deriveEventsById, deriveEventsAsc} from "@welshman/store" import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
@@ -18,7 +18,6 @@
import CommentActions from "@app/components/CommentActions.svelte" import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte" import EventReply from "@app/components/EventReply.svelte"
import {deriveEvent, decodeRelay} from "@app/core/state" import {deriveEvent, decodeRelay} from "@app/core/state"
import {Poll, PollResponse} from "nostr-tools/kinds"
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params> const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay) const url = decodeRelay(relay)
@@ -48,7 +47,7 @@
request({ request({
relays: [url], relays: [url],
filters: [{kinds: [Poll], ids: [id]}, {kinds: [PollResponse], "#e": [id]}, ...filters], filters: [{kinds: [POLL], ids: [id]}, {kinds: [POLL_RESPONSE], "#e": [id]}, ...filters],
signal: controller.signal, signal: controller.signal,
}) })
@@ -25,6 +25,7 @@
ZAP_GOAL, ZAP_GOAL,
EVENT_TIME, EVENT_TIME,
COMMENT, COMMENT,
POLL,
getTagValue, getTagValue,
getTagValues, getTagValues,
getIdAndAddress, getIdAndAddress,
@@ -50,7 +51,6 @@
import RecentConversation from "@app/components/RecentConversation.svelte" import RecentConversation from "@app/components/RecentConversation.svelte"
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state" import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state"
import {goToEvent} from "@app/util/routes" import {goToEvent} from "@app/util/routes"
import {Poll} from "nostr-tools/kinds"
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
const since = ago(3, MONTH) const since = ago(3, MONTH)
@@ -305,7 +305,7 @@
<GoalItem {url} {event} /> <GoalItem {url} {event} />
{:else if event.kind === EVENT_TIME} {:else if event.kind === EVENT_TIME}
<CalendarEventItem {url} {event} /> <CalendarEventItem {url} {event} />
{:else if event.kind === Poll} {:else if event.kind === POLL}
<PollItem {url} {event} /> <PollItem {url} {event} />
{:else} {:else}
<NoteItem {url} {event} /> <NoteItem {url} {event} />