forked from coracle/flotilla
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 964ef441ec | |||
| 796f37d320 | |||
| b46fd94578 | |||
| bdc8e75640 | |||
| ef08821796 | |||
| 9f386f6968 | |||
| ec0b6a99e2 | |||
| f6d9e52c6e | |||
| 90f86b833d | |||
| 29bb33c26c | |||
| c740bd21d4 | |||
| 1d92709c76 | |||
| a42e1df1a7 | |||
| e33beee17d | |||
| b10ea04cb3 | |||
| e8c94177ca | |||
| f1f2083c88 | |||
| f42889c3c2 | |||
| a75e1f96eb | |||
| 85c5293082 | |||
| 37efa6a62c | |||
| 1d5f91fb6c | |||
| ef18655776 | |||
| b786e858d9 | |||
| f4ebc4e99e |
@@ -13,4 +13,4 @@ ios/App/Pods/
|
||||
android/capacitor-cordova-android-plugins
|
||||
android/app/src/androidTest
|
||||
android/app/src/test
|
||||
|
||||
node_modules
|
||||
|
||||
@@ -70,6 +70,7 @@ GoogleService-Info.plist
|
||||
.roo
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
|
||||
# OS generated
|
||||
.DS_Store
|
||||
|
||||
@@ -44,4 +44,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.getcapacitor.JSObject
|
||||
@@ -76,6 +77,7 @@ class AndroidPushFallbackPlugin : Plugin() {
|
||||
|
||||
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
|
||||
+9
-10
@@ -43,7 +43,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
||||
private const val TAG = "PushFallback"
|
||||
private const val CHANNEL_ID = "flotilla_fallback"
|
||||
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
|
||||
private const val SOCKET_TIMEOUT_SECONDS = 20L
|
||||
private const val SOCKET_TIMEOUT_SECONDS = 30L
|
||||
private const val REJECTED = "__REJECTED__"
|
||||
private const val KIND_RELAY_AUTH = 22242
|
||||
private const val KIND_NIP46_RPC = 24133
|
||||
@@ -72,6 +72,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
Log.i(TAG, "doWork() started")
|
||||
|
||||
if (isAppInForeground()) {
|
||||
return Result.success()
|
||||
}
|
||||
@@ -88,7 +90,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
||||
|
||||
val activeSince = state.optLong("activeSince", 0L)
|
||||
val seen = mutableSetOf<String>()
|
||||
var latestPair: Pair<String, JSONObject>? = null
|
||||
val newEvents = mutableListOf<Pair<String, JSONObject>>()
|
||||
|
||||
for (sub in subscriptions) {
|
||||
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
||||
@@ -102,23 +104,19 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
||||
for (event in result.events) {
|
||||
val id = event.optString("id", "")
|
||||
if (id.isNotEmpty() && seen.add(id)) {
|
||||
val createdAt = event.optLong("created_at", 0L)
|
||||
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
|
||||
latestPair = Pair(sub.relay, event)
|
||||
}
|
||||
newEvents.add(Pair(sub.relay, event))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestPair != null) {
|
||||
val (relay, event) = latestPair!!
|
||||
for ((relay, event) in newEvents) {
|
||||
postNotification(relay, event)
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Worker failed", e)
|
||||
return Result.success()
|
||||
return Result.retry()
|
||||
} finally {
|
||||
pool.closeAll()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
@@ -214,7 +212,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(context).notify(1, notification)
|
||||
val notificationId = id.hashCode().let { if (it == 0) 1 else it }
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
}
|
||||
|
||||
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Flotilla uses the camera when you enable it in a voice room.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Flotilla uses the microphone for voice chat in rooms.</string>
|
||||
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
|
||||
+3
-1
@@ -48,6 +48,7 @@
|
||||
"@capacitor/android": "^8.0.1",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/cli": "^8.0.1",
|
||||
"@capacitor/clipboard": "^8.0.1",
|
||||
"@capacitor/core": "^8.0.1",
|
||||
"@capacitor/filesystem": "^8.1.0",
|
||||
"@capacitor/ios": "^8.0.1",
|
||||
@@ -59,10 +60,11 @@
|
||||
"@getalby/lightning-tools": "^6.1.0",
|
||||
"@getalby/sdk": "^5.1.2",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@pomade/core": "^0.2.2",
|
||||
"@pomade/core": "^0.2.3",
|
||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@tiptap/core": "^2.27.2",
|
||||
"@tiptap/pm": "^2.27.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
|
||||
Generated
+24
-9
@@ -26,6 +26,9 @@ importers:
|
||||
'@capacitor/cli':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
'@capacitor/clipboard':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1(@capacitor/core@8.0.1)
|
||||
'@capacitor/core':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
@@ -60,8 +63,8 @@ importers:
|
||||
specifier: ^1.9.7
|
||||
version: 1.9.7
|
||||
'@pomade/core':
|
||||
specifier: ^0.2.2
|
||||
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))
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3(@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))
|
||||
'@poppanator/sveltekit-svg':
|
||||
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))
|
||||
@@ -71,6 +74,9 @@ importers:
|
||||
'@tiptap/core':
|
||||
specifier: ^2.27.2
|
||||
version: 2.27.2(@tiptap/pm@2.27.2)
|
||||
'@tiptap/pm':
|
||||
specifier: ^2.27.2
|
||||
version: 2.27.2
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
@@ -85,7 +91,7 @@ importers:
|
||||
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':
|
||||
specifier: ^0.8.12
|
||||
version: 0.8.12(3074ef6691f94dc03952d8dbc98013a7)
|
||||
version: 0.8.12(2f5bd20a84c8c39e26176b5a5db083ae)
|
||||
'@welshman/content':
|
||||
specifier: ^0.8.12
|
||||
version: 0.8.12(nostr-tools@2.20.0(typescript@5.9.3))
|
||||
@@ -791,6 +797,11 @@ packages:
|
||||
engines: {node: '>=22.0.0'}
|
||||
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':
|
||||
resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==}
|
||||
|
||||
@@ -1427,9 +1438,9 @@ packages:
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@pomade/core@0.2.2':
|
||||
resolution: {integrity: sha512-FoilLsO0gVjiKMW3LV63pmXU7x3gh8YVGVulyR6QJr4h47XrsBg8vPkZtKWr4+sH3sW31e2tNIPUb3ptiuhrMA==}
|
||||
version: 0.2.2
|
||||
'@pomade/core@0.2.3':
|
||||
resolution: {integrity: sha512-36+abWfMH1Mif9FjBO7xICCkGZE4IqQpy7Csxlauyt0bhYQ9GsB07LqK5Qm3GgEHNwF9rFXdSZawkM+E6BeIfg==}
|
||||
version: 0.2.3
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
'@frostr/bifrost': ^1.0.7
|
||||
@@ -5969,6 +5980,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@capacitor/clipboard@8.0.1(@capacitor/core@8.0.1)':
|
||||
dependencies:
|
||||
'@capacitor/core': 8.0.1
|
||||
|
||||
'@capacitor/core@8.0.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -6610,7 +6625,7 @@ snapshots:
|
||||
|
||||
'@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.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))':
|
||||
dependencies:
|
||||
'@frostr/bifrost': 1.0.7(typescript@5.9.3)
|
||||
'@noble/hashes': 2.0.1
|
||||
@@ -7276,9 +7291,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@vite-pwa/assets-generator': 0.2.6
|
||||
|
||||
'@welshman/app@0.8.12(3074ef6691f94dc03952d8dbc98013a7)':
|
||||
'@welshman/app@0.8.12(2f5bd20a84c8c39e26176b5a5db083ae)':
|
||||
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.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))
|
||||
'@welshman/feeds': 0.8.12(d5b74f0c83250e052e0b96f7ff5804e8)
|
||||
'@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)
|
||||
|
||||
+12
-2
@@ -22,6 +22,16 @@
|
||||
@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 {
|
||||
@apply pt-sai pb-sai;
|
||||
}
|
||||
@@ -317,7 +327,7 @@
|
||||
|
||||
.note-editor .tiptap {
|
||||
--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 {
|
||||
@@ -415,7 +425,7 @@ body.keyboard-open .hide-on-keyboard {
|
||||
/* chat view */
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,35 @@
|
||||
*/
|
||||
import {
|
||||
DisconnectReason,
|
||||
LocalParticipant,
|
||||
LocalTrackPublication,
|
||||
Room as LiveKitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
supportsAudioOutputSelection,
|
||||
type AudioCaptureOptions,
|
||||
type LocalParticipant,
|
||||
} from "livekit-client"
|
||||
import {derived, get, writable} from "svelte/store"
|
||||
import {derived, get} from "svelte/store"
|
||||
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
||||
import {signer} from "@welshman/app"
|
||||
import {getLivekitEndpoint} from "$lib/livekit"
|
||||
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state"
|
||||
import {
|
||||
currentVoiceRoom,
|
||||
currentVoiceSession,
|
||||
participantFromLiveKitIdentity,
|
||||
participantKey,
|
||||
participantPubkeyMap,
|
||||
pubkeyFromLiveKitIdentity,
|
||||
speakingParticipants,
|
||||
VoiceState,
|
||||
type VoiceParticipant,
|
||||
voiceState,
|
||||
} from "@app/call/stores"
|
||||
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
||||
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
export const LIVEKIT_PARTICIPANTS = 39004
|
||||
@@ -27,30 +41,12 @@ export {checkRelayHasLivekit} from "$lib/livekit"
|
||||
|
||||
export {supportsAudioOutputSelection}
|
||||
|
||||
export type VoiceSession = {
|
||||
url: string
|
||||
h: string
|
||||
room: LiveKitRoom
|
||||
muted: boolean
|
||||
}
|
||||
|
||||
export type Pubkey = string
|
||||
|
||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||
|
||||
export enum VoiceState {
|
||||
Joining = "joining",
|
||||
Connected = "connected",
|
||||
Disconnected = "disconnected",
|
||||
}
|
||||
|
||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||
|
||||
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||
|
||||
export enum DeviceKind {
|
||||
AudioInput = "audioinput",
|
||||
AudioOutput = "audiooutput",
|
||||
VideoInput = "videoinput",
|
||||
}
|
||||
|
||||
export const switchVoiceActiveDevice = async (
|
||||
@@ -71,17 +67,14 @@ export const switchVoiceActiveDevice = async (
|
||||
case DeviceKind.AudioOutput:
|
||||
label = "speaker"
|
||||
break
|
||||
case DeviceKind.VideoInput:
|
||||
label = "camera"
|
||||
break
|
||||
}
|
||||
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||
}
|
||||
}
|
||||
|
||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||
|
||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||
|
||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||
|
||||
const addParticipant = (identity: string) => {
|
||||
participantPubkeyMap.update(m => {
|
||||
const next = new Map(m)
|
||||
@@ -98,24 +91,6 @@ const deleteParticipant = (identity: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||
|
||||
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||
return pk ? {pubkey: pk, identity} : {identity}
|
||||
}
|
||||
|
||||
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||
|
||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||
|
||||
export const isParticipantSpeaking = derived(
|
||||
speakingParticipants,
|
||||
$participants => (p: VoiceParticipant) =>
|
||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||
)
|
||||
|
||||
const fetchLivekitToken = async (
|
||||
url: string,
|
||||
groupId: string,
|
||||
@@ -197,7 +172,9 @@ const setUpMicrophone = async (
|
||||
}
|
||||
|
||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
const message =
|
||||
@@ -216,11 +193,16 @@ const onTrackSubscribed = (track: Track) => {
|
||||
element.style.display = "none"
|
||||
document.body.appendChild(element)
|
||||
element.play().catch(() => {})
|
||||
} else if (track.kind === Track.Kind.Video) {
|
||||
triggerVideoFeedCount()
|
||||
}
|
||||
}
|
||||
|
||||
const onTrackUnsubscribed = (track: Track) => {
|
||||
track.detach().forEach(el => el.remove())
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
triggerVideoFeedCount()
|
||||
}
|
||||
}
|
||||
|
||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||
@@ -241,6 +223,17 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
|
||||
deleteParticipant(participant.identity)
|
||||
}
|
||||
|
||||
const onLocalTrackUnpublished = (
|
||||
publication: LocalTrackPublication,
|
||||
participant: LocalParticipant,
|
||||
) => {
|
||||
if (publication.source !== Track.Source.ScreenShare) return
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session || participant.identity !== session.room.localParticipant.identity) return
|
||||
if (!session.screenShareOn) return
|
||||
currentVoiceSession.set({...session, screenShareOn: false})
|
||||
}
|
||||
|
||||
let joinAbortController: AbortController | undefined
|
||||
|
||||
export const cancelJoinVoiceRoom = () => {
|
||||
@@ -278,6 +271,7 @@ export const joinVoiceRoom = async (
|
||||
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
|
||||
try {
|
||||
@@ -301,7 +295,14 @@ export const joinVoiceRoom = async (
|
||||
|
||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||
|
||||
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
|
||||
currentVoiceSession.set({
|
||||
url,
|
||||
h,
|
||||
room: liveKitRoom,
|
||||
muted,
|
||||
cameraOn: false,
|
||||
screenShareOn: false,
|
||||
})
|
||||
voiceState.set(VoiceState.Connected)
|
||||
playJoinSound()
|
||||
} catch (e) {
|
||||
@@ -320,8 +321,26 @@ export const leaveVoiceRoom = async () => {
|
||||
const audio = new Audio("/leave-voice-room.mp3")
|
||||
audio.play().catch(() => {})
|
||||
|
||||
if (session.cameraOn) {
|
||||
try {
|
||||
await session.room.localParticipant.setCameraEnabled(false)
|
||||
} catch {
|
||||
pushToast({theme: "error", message: "Error turning off camera."})
|
||||
}
|
||||
}
|
||||
|
||||
if (session.screenShareOn) {
|
||||
try {
|
||||
await session.room.localParticipant.setScreenShareEnabled(false)
|
||||
} catch {
|
||||
pushToast({theme: "error", message: "Error turning off screen sharing."})
|
||||
}
|
||||
}
|
||||
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
session.room.disconnect()
|
||||
speakingParticipants.set([])
|
||||
participantPubkeyMap.set(new Map())
|
||||
@@ -7,12 +7,13 @@
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
shareToChat?: boolean
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
const {url, h, shareToChat = false}: Props = $props()
|
||||
</script>
|
||||
|
||||
<CalendarEventForm {url} {h}>
|
||||
<CalendarEventForm {url} {h} {shareToChat}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<ModalTitle>Create an Event</ModalTitle>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {writable} from "svelte/store"
|
||||
import {randomId, HOUR} from "@welshman/lib"
|
||||
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
||||
import {publishThunk} from "@welshman/app"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {daysBetween} from "@lib/util"
|
||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||
@@ -22,7 +22,7 @@
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/util/drafts"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {canEnforceNip70} from "@app/core/commands"
|
||||
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
|
||||
|
||||
type Values = {
|
||||
d: string
|
||||
@@ -36,11 +36,12 @@
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
shareToChat?: boolean
|
||||
header: Snippet
|
||||
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 ?? ""}`)
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
if ($uploading || loading) return
|
||||
|
||||
if (!title) {
|
||||
return pushToast({
|
||||
@@ -92,22 +93,42 @@
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
]
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
const event = makeEvent(EVENT_TIME, {content, tags})
|
||||
const calendarThunk = publishThunk({event, relays: [url]})
|
||||
const error = await waitForThunkError(calendarThunk)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
|
||||
if (shareToChat) {
|
||||
publishRoomQuote({url, h, parent: calendarThunk.event, protect})
|
||||
}
|
||||
|
||||
pushToast({message: "Your event has been saved!"})
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
const event = makeEvent(EVENT_TIME, {content, tags})
|
||||
|
||||
pushToast({message: "Your event has been saved!"})
|
||||
publishThunk({event, relays: [url]})
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
|
||||
const d = $state(initialValues?.d ?? randomId())
|
||||
let title = $state(initialValues?.title ?? "")
|
||||
let location = $state(initialValues?.location ?? "")
|
||||
@@ -158,7 +179,11 @@
|
||||
<div class="input-editor grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</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}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
@@ -197,12 +222,12 @@
|
||||
</Field>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
|
||||
<Spinner loading={$uploading}>Save Event</Spinner>
|
||||
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
|
||||
<Spinner loading={$uploading || loading}>Save Event</Spinner>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
</div>
|
||||
</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}
|
||||
<div class="py-12">
|
||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
shareToChat?: boolean
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
const {url, h, shareToChat = false}: Props = $props()
|
||||
</script>
|
||||
|
||||
<ClassifiedForm {url} {h}>
|
||||
<ClassifiedForm {url} {h} {shareToChat}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<ModalTitle>Create a Classified Listing</ModalTitle>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type {Snippet} from "svelte"
|
||||
import {removeUndefined, randomId, uniq} from "@welshman/lib"
|
||||
import {makeEvent, CLASSIFIED} from "@welshman/util"
|
||||
import {publishThunk} from "@welshman/app"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
@@ -21,7 +21,7 @@
|
||||
import {PROTECTED} from "@app/core/state"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/util/drafts"
|
||||
import {canEnforceNip70, uploadFile} from "@app/core/commands"
|
||||
import {canEnforceNip70, publishRoomQuote, uploadFile} from "@app/core/commands"
|
||||
|
||||
type Values = {
|
||||
d: string
|
||||
@@ -37,11 +37,12 @@
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
shareToChat?: boolean
|
||||
header: Snippet
|
||||
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 ?? ""}`)
|
||||
|
||||
@@ -87,7 +88,9 @@
|
||||
tags.push(["t", topic])
|
||||
}
|
||||
|
||||
if (await shouldProtect) {
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
@@ -114,13 +117,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
publishThunk({
|
||||
const classifiedThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(CLASSIFIED, {content, tags}),
|
||||
})
|
||||
|
||||
const error = await waitForThunkError(classifiedThunk)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
|
||||
if (shareToChat) {
|
||||
publishRoomQuote({url, h, parent: classifiedThunk.event, protect})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
||||
@@ -22,15 +22,15 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@
|
||||
</script>
|
||||
|
||||
{#if isRoomOrRelay}
|
||||
<ContentLinkUrl {url} class="bg-alt my-2 block p-4 leading-normal whitespace-nowrap" />
|
||||
<div>
|
||||
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
||||
</div>
|
||||
{:else}
|
||||
<Link {external} {href} class="my-2 block">
|
||||
<div class="overflow-hidden rounded-box">
|
||||
|
||||
@@ -37,21 +37,6 @@
|
||||
return normalizeRelayUrl(url)
|
||||
})
|
||||
|
||||
const label = call(() => {
|
||||
if (roomReference) {
|
||||
const spaceName = displayRelayUrl(roomReference.url)
|
||||
const roomName = displayRoom(roomReference.url, roomReference.h)
|
||||
|
||||
return `~${spaceName} / ${roomName}`
|
||||
}
|
||||
|
||||
if (relayReference) {
|
||||
return `~${displayRelayUrl(relayReference)}`
|
||||
}
|
||||
|
||||
return displayUrl(url)
|
||||
})
|
||||
|
||||
const [href, external] = call(() => {
|
||||
if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false]
|
||||
if (relayReference) return [makeSpacePath(relayReference), false]
|
||||
@@ -62,6 +47,13 @@
|
||||
</script>
|
||||
|
||||
<Link {external} {href} class={className}>
|
||||
<Icon icon={LinkRound} size={3} class="inline-block" />
|
||||
<span class="ml-2">{label}</span>
|
||||
{#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>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<NoteContentMinimal trimParent {url} event={$quote} />
|
||||
</div>
|
||||
{: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} />
|
||||
</NoteCard>
|
||||
{/if}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
})
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
spacer!.style.minHeight = `${form!.offsetHeight}px`
|
||||
spacer!.style.minHeight = `${form!.offsetHeight + 60}px`
|
||||
})
|
||||
|
||||
observer.observe(form!)
|
||||
@@ -84,7 +84,7 @@
|
||||
in:fly
|
||||
bind:this={form}
|
||||
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="relative">
|
||||
<div class="note-editor grow overflow-hidden">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {writable} from "svelte/store"
|
||||
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 Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
@@ -10,6 +10,7 @@
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
@@ -21,7 +22,7 @@
|
||||
import {PROTECTED} from "@app/core/state"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/util/drafts"
|
||||
import {canEnforceNip70} from "@app/core/commands"
|
||||
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
|
||||
|
||||
type Values = {
|
||||
title: string
|
||||
@@ -33,9 +34,10 @@
|
||||
url: string
|
||||
h?: string
|
||||
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 ?? ""}`)
|
||||
|
||||
@@ -52,7 +54,7 @@
|
||||
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
if ($uploading || loading) return
|
||||
|
||||
if (!title) {
|
||||
return pushToast({
|
||||
@@ -78,23 +80,43 @@
|
||||
["relays", url],
|
||||
]
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
const 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 amount = $state(initialValues?.amount ?? 1000)
|
||||
let content = $state(initialValues?.content ?? "")
|
||||
@@ -154,7 +176,8 @@
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="tooltip tooltip-left absolute bottom-1 right-2"
|
||||
onclick={selectFiles}>
|
||||
onclick={selectFiles}
|
||||
disabled={loading}>
|
||||
{#if $uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
@@ -169,16 +192,16 @@
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<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} />
|
||||
<input bind:value={amount} type="number" class="w-28" />
|
||||
<p class="opacity-50">sats</p>
|
||||
<input bind:value={amount} type="number" class="w-28 grow" />
|
||||
<p class="shrink-0 opacity-50">sats</p>
|
||||
</label>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<input
|
||||
class="range range-primary -mt-2"
|
||||
class="range range-primary -mt-2 w-full"
|
||||
type="range"
|
||||
min="1000"
|
||||
max="100000"
|
||||
@@ -188,10 +211,12 @@
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</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>
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {onMount, onDestroy} from "svelte"
|
||||
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
||||
import {Nip46Broker} from "@welshman/signer"
|
||||
@@ -103,10 +104,16 @@
|
||||
mode = "connect"
|
||||
}
|
||||
|
||||
const openSigner = () => {
|
||||
controller.launchSigner()
|
||||
}
|
||||
|
||||
const selectBunker = () => {
|
||||
mode = "bunker"
|
||||
}
|
||||
|
||||
const isIos = Capacitor.getPlatform() === "ios"
|
||||
|
||||
let mode: string = $state("bunker")
|
||||
|
||||
$effect(() => {
|
||||
@@ -138,6 +145,9 @@
|
||||
<BunkerUrl {controller} />
|
||||
<Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect}
|
||||
>Log in with a QR code instead</Button>
|
||||
{#if isIos}
|
||||
<Button class="btn btn-neutral" onclick={openSigner}>Open in Signer</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
children,
|
||||
minimal = false,
|
||||
hideProfile = false,
|
||||
noShadow = false,
|
||||
url,
|
||||
...restProps
|
||||
}: {
|
||||
@@ -23,6 +24,7 @@
|
||||
children: Snippet
|
||||
minimal?: boolean
|
||||
hideProfile?: boolean
|
||||
noShadow?: boolean
|
||||
url?: string
|
||||
class?: string
|
||||
} = $props()
|
||||
@@ -34,7 +36,7 @@
|
||||
let muted = $state($isEventMuted(event))
|
||||
</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}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="row-2 relative">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
|
||||
import {makeEvent} 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 AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
@@ -13,6 +13,7 @@
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
@@ -21,7 +22,7 @@
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
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 type {PollType} from "@app/util/polls"
|
||||
|
||||
@@ -40,9 +41,10 @@
|
||||
type Props = {
|
||||
url: 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 initialValues = draftKey.get()
|
||||
|
||||
@@ -102,6 +104,8 @@
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return
|
||||
|
||||
if (!title.trim()) {
|
||||
return pushToast({theme: "error", message: "Please provide a title for your poll."})
|
||||
}
|
||||
@@ -130,19 +134,39 @@
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
const pollThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(Poll, {content: title.trim(), tags}),
|
||||
})
|
||||
|
||||
const error = await waitForThunkError(pollThunk)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
|
||||
if (shareToChat) {
|
||||
publishRoomQuote({url, h, parent: pollThunk.event, protect})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
||||
publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(Poll, {content: title.trim(), tags}),
|
||||
})
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
|
||||
let draggedOptionId = $state<string | undefined>()
|
||||
let title = $state(initialValues?.title ?? "")
|
||||
let pollType = $state<PollType>(initialValues?.pollType ?? "singlechoice")
|
||||
@@ -246,10 +270,12 @@
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</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>
|
||||
</Modal>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {deriveEventsDesc, deriveEventsById} from "@welshman/store"
|
||||
import {formatTimestampRelative} from "@welshman/lib"
|
||||
import {NOTE, ROOMS, COMMENT} from "@welshman/util"
|
||||
import {NOTE, ROOMS, COMMENT, MESSAGE} from "@welshman/util"
|
||||
import {repository, loadRelayList} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
|
||||
import {deriveGroupList, getSpaceUrlsFromGroupList, MESSAGE_KINDS} from "@app/core/state"
|
||||
import {deriveGroupList, getSpaceUrlsFromGroupList} from "@app/core/state"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
load({
|
||||
filters: [
|
||||
{authors: [pubkey], kinds: [ROOMS]},
|
||||
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, ...MESSAGE_KINDS]},
|
||||
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, MESSAGE]},
|
||||
],
|
||||
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
url?: string
|
||||
reactionClass?: string
|
||||
noTooltip?: boolean
|
||||
innerEvent?: TrustedEvent
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
@@ -43,23 +44,36 @@
|
||||
url = "",
|
||||
reactionClass = "",
|
||||
noTooltip = false,
|
||||
innerEvent = undefined,
|
||||
children,
|
||||
}: Props = $props()
|
||||
|
||||
const eventIds = innerEvent ? [event.id, innerEvent.id] : [event.id]
|
||||
|
||||
const reports = deriveArray(
|
||||
deriveEventsById({repository, filters: [{kinds: [REPORT], "#e": [event.id]}]}),
|
||||
)
|
||||
|
||||
const reactions = deriveArray(
|
||||
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": [event.id]}]}),
|
||||
deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": eventIds}]}),
|
||||
)
|
||||
|
||||
const zaps = deriveArray(
|
||||
deriveItemsByKey<Zap>({
|
||||
repository,
|
||||
getKey: zap => zap.response.id,
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": eventIds}],
|
||||
eventToItem: (response: TrustedEvent) => {
|
||||
const zap = getValidZap(response, event)
|
||||
|
||||
if (zap) {
|
||||
return zap
|
||||
}
|
||||
|
||||
if (innerEvent) {
|
||||
return getValidZap(response, innerEvent)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
{/if}
|
||||
</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="flex items-center gap-4">
|
||||
<span>Members:</span>
|
||||
@@ -251,6 +251,10 @@
|
||||
</div>
|
||||
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
|
||||
</div>
|
||||
{:else if $members === undefined}
|
||||
<div class="card2 card2-sm bg-base-200 flex items-center gap-4">
|
||||
<span class="text-error">Member list not available from this relay</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card2 card2-sm bg-alt col-4">
|
||||
<strong class="text-lg">Room Settings</strong>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<script lang="ts">
|
||||
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 {MESSAGE, COMMENT} from "@welshman/util"
|
||||
import {MESSAGE, COMMENT, getTag} from "@welshman/util"
|
||||
import {
|
||||
thunks,
|
||||
pubkey,
|
||||
@@ -27,7 +35,7 @@
|
||||
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
|
||||
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
|
||||
import RoomItemContent from "@app/components/RoomItemContent.svelte"
|
||||
import {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 {getRoomItemPath} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
@@ -38,7 +46,6 @@
|
||||
replyTo?: (event: TrustedEvent) => void
|
||||
showPubkey?: boolean
|
||||
addSpaceBelow?: boolean
|
||||
inert?: boolean
|
||||
canEdit: (event: TrustedEvent) => boolean
|
||||
onEdit: (event: TrustedEvent) => void
|
||||
}
|
||||
@@ -49,7 +56,6 @@
|
||||
replyTo = undefined,
|
||||
showPubkey = false,
|
||||
addSpaceBelow = false,
|
||||
inert = false,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: Props = $props()
|
||||
@@ -60,7 +66,15 @@
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
||||
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||
const 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 edit = canEdit(event) ? () => onEdit(event) : undefined
|
||||
@@ -78,7 +92,7 @@
|
||||
|
||||
<TapTarget
|
||||
data-event={event.id}
|
||||
onTap={inert ? null : onTap}
|
||||
{onTap}
|
||||
class={cx(
|
||||
"group relative flex w-full cursor-default flex-col px-2 py-0.5 text-left hover:bg-base-100/50",
|
||||
{"mt-1.5": showPubkey, "mb-1.5": addSpaceBelow},
|
||||
@@ -111,7 +125,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
|
||||
<RoomItemContent {url} {event} />
|
||||
<RoomItemContent {url} event={$innerEvent ?? event} />
|
||||
{#if thunk}
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
|
||||
{/if}
|
||||
@@ -124,9 +138,10 @@
|
||||
{event}
|
||||
{deleteReaction}
|
||||
{createReaction}
|
||||
reactionClass="tooltip-right" />
|
||||
{#if path && $comments.length > 0}
|
||||
{@const pubkeys = $comments.map(e => e.pubkey)}
|
||||
reactionClass="tooltip-right"
|
||||
innerEvent={$innerEvent} />
|
||||
{#if path && $innerComments.length > 0}
|
||||
{@const pubkeys = $innerComments.map(e => e.pubkey)}
|
||||
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
|
||||
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
|
||||
{@const tooltip = `${info} commented`}
|
||||
@@ -138,7 +153,7 @@
|
||||
"btn-primary": isOwn,
|
||||
})}>
|
||||
<Icon icon={ReplyAlt} />
|
||||
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
|
||||
<span>{$innerComments.length} comment{$innerComments.length === 1 ? "" : "s"}</span>
|
||||
</Link>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -8,16 +8,17 @@
|
||||
import {getRoomItemPath} from "@app/util/routes"
|
||||
|
||||
const props: ComponentProps<typeof NoteContent> = $props()
|
||||
|
||||
const path = getRoomItemPath(props.url!, props.event)
|
||||
const minLength = 5000
|
||||
const maxLength = 5500
|
||||
</script>
|
||||
|
||||
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
|
||||
{#if path && !isMobile}
|
||||
<Link href={path}>
|
||||
<NoteContent {...props} />
|
||||
<NoteContent {...props} {minLength} {maxLength} />
|
||||
</Link>
|
||||
{:else}
|
||||
<NoteContent {...props} />
|
||||
<NoteContent {...props} {minLength} {maxLength} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -73,34 +73,44 @@
|
||||
</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each $members as pubkey (pubkey)}
|
||||
<div class="card2 bg-alt relative">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Profile {pubkey} {url} />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
|
||||
<Icon icon={MenuDots} />
|
||||
</Button>
|
||||
{#if menuPubkey === pubkey}
|
||||
<Popover hideOnClick onClose={closeMenu}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||
<li>
|
||||
<Button class="text-error" onclick={() => removeMember(pubkey)}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Remove Member
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if $members === undefined}
|
||||
<div class="card2 bg-base-200 p-4">
|
||||
<span class="text-error">Member list not available from this relay</span>
|
||||
</div>
|
||||
{:else if $members.length === 0}
|
||||
<div class="card2 bg-base-200 p-4">
|
||||
<span class="text-base-content/70">No members yet</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each $members as pubkey (pubkey)}
|
||||
<div class="card2 bg-alt relative">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Profile {pubkey} {url} />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
|
||||
<Icon icon={MenuDots} />
|
||||
</Button>
|
||||
{#if menuPubkey === pubkey}
|
||||
<Popover hideOnClick onClose={closeMenu}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||
<li>
|
||||
<Button class="text-error" onclick={() => removeMember(pubkey)}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Remove Member
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -56,6 +56,11 @@
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!$spaceMembers) {
|
||||
addMembers()
|
||||
return
|
||||
}
|
||||
|
||||
const pubkeysSnapshot = $state.snapshot(pubkeys)
|
||||
const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey))
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</script>
|
||||
|
||||
<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} />
|
||||
<div class="min-w-0 overflow-hidden text-ellipsis">
|
||||
<RoomName {url} {h} />
|
||||
|
||||
@@ -112,46 +112,58 @@
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#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>
|
||||
{#if $members === undefined}
|
||||
<div class="card2 bg-base-200 p-4">
|
||||
<span class="text-error">Member list not available from this space</span>
|
||||
</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>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -181,7 +181,11 @@
|
||||
<li>
|
||||
<Button onclick={showMembers}>
|
||||
<Icon icon={UserRounded} />
|
||||
View Members ({$members.length})
|
||||
{#if $members === undefined}
|
||||
View Members
|
||||
{:else}
|
||||
View Members ({$members.length})
|
||||
{/if}
|
||||
</Button>
|
||||
</li>
|
||||
{#if $userIsAdmin}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import {writable} from "svelte/store"
|
||||
import {makeEvent, THREAD} from "@welshman/util"
|
||||
import {publishThunk} from "@welshman/app"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
@@ -19,7 +20,7 @@
|
||||
import {PROTECTED} from "@app/core/state"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/util/drafts"
|
||||
import {canEnforceNip70} from "@app/core/commands"
|
||||
import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
|
||||
|
||||
type Values = {
|
||||
content?: string | object
|
||||
@@ -29,9 +30,10 @@
|
||||
type Props = {
|
||||
url: 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 initialValues = draftKey.get()
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
@@ -43,7 +45,7 @@
|
||||
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
if ($uploading || loading) return
|
||||
|
||||
if (!title) {
|
||||
return pushToast({
|
||||
@@ -64,23 +66,43 @@
|
||||
|
||||
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]]
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
const 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 content = $state(initialValues?.content ?? "")
|
||||
|
||||
@@ -138,7 +160,8 @@
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="tooltip tooltip-left absolute bottom-1 right-2"
|
||||
onclick={selectFiles}>
|
||||
onclick={selectFiles}
|
||||
disabled={loading}>
|
||||
{#if $uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
@@ -148,10 +171,12 @@
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</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>
|
||||
</Modal>
|
||||
|
||||
@@ -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}
|
||||
@@ -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 ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import {
|
||||
currentVoiceSession,
|
||||
DeviceKind,
|
||||
supportsAudioOutputSelection,
|
||||
switchVoiceActiveDevice,
|
||||
type VoiceSession,
|
||||
} from "@app/voice"
|
||||
import {currentVoiceSession, type VoiceSession} from "@app/call/stores"
|
||||
import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice"
|
||||
import {popModal} from "@app/util/modal"
|
||||
|
||||
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
|
||||
@@ -26,8 +21,10 @@
|
||||
|
||||
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||
let audioOutputs = $state<MediaDeviceInfo[]>([])
|
||||
let videoInputs = $state<MediaDeviceInfo[]>([])
|
||||
let selectedInput = $state("")
|
||||
let selectedOutput = $state("")
|
||||
let selectedVideo = $state("")
|
||||
|
||||
const loadDevices = async () => {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||
@@ -35,9 +32,11 @@
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
audioInputs = devices.filter(d => d.kind === "audioinput")
|
||||
audioOutputs = devices.filter(d => d.kind === "audiooutput")
|
||||
videoInputs = devices.filter(d => d.kind === "videoinput")
|
||||
} catch {
|
||||
audioInputs = []
|
||||
audioOutputs = []
|
||||
videoInputs = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +54,7 @@
|
||||
}
|
||||
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
||||
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
||||
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
|
||||
})
|
||||
|
||||
const onInputChange = () => {
|
||||
@@ -65,6 +65,10 @@
|
||||
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
||||
}
|
||||
|
||||
const onVideoChange = () => {
|
||||
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
|
||||
}
|
||||
|
||||
const onDone = () => {
|
||||
popModal()
|
||||
}
|
||||
@@ -76,8 +80,8 @@
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Audio settings</ModalTitle>
|
||||
<ModalSubtitle>Choose microphone and speaker for this call.</ModalSubtitle>
|
||||
<ModalTitle>Call settings</ModalTitle>
|
||||
<ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="flex flex-col gap-4 pt-2">
|
||||
<FieldInline>
|
||||
@@ -120,6 +124,25 @@
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{/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>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -12,14 +12,13 @@
|
||||
import {makeRoomId} from "@app/core/state"
|
||||
import {
|
||||
VoiceState,
|
||||
deriveVoiceParticipants,
|
||||
cancelJoinVoiceRoom,
|
||||
currentVoiceRoom,
|
||||
voiceState,
|
||||
isParticipantSpeaking,
|
||||
participantKey,
|
||||
voiceState,
|
||||
type VoiceParticipant,
|
||||
} from "@app/voice"
|
||||
} from "@app/call/stores"
|
||||
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import {AbortError, TimeoutError} from "$lib/util"
|
||||
import {displayRoom} from "@app/core/state"
|
||||
import {joinVoiceRoom} from "@app/voice"
|
||||
import {joinVoiceRoom} from "@app/call/voice"
|
||||
import {popModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script lang="ts">
|
||||
import {readable} from "svelte/store"
|
||||
import {fly} from "svelte/transition"
|
||||
import {fade, fly} from "svelte/transition"
|
||||
import {goto} from "$app/navigation"
|
||||
import {page} from "$app/stores"
|
||||
import cx from "classnames"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
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 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 Settings from "@assets/icons/settings.svg?dataurl"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
|
||||
@@ -23,16 +28,23 @@
|
||||
type Room,
|
||||
} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
import {makeRoomPath} from "@app/util/routes"
|
||||
import {
|
||||
VideoCallLayout,
|
||||
isDesktopLayout,
|
||||
toggleCamera,
|
||||
toggleScreenShare,
|
||||
videoCallLayout,
|
||||
} from "@app/call/video"
|
||||
import {
|
||||
VoiceState,
|
||||
currentVoiceSession,
|
||||
currentVoiceRoom,
|
||||
voiceState,
|
||||
leaveVoiceRoom,
|
||||
toggleMute,
|
||||
cancelJoinVoiceRoom,
|
||||
} from "@app/voice"
|
||||
isLocalSpeaking,
|
||||
} from "@app/call/stores"
|
||||
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
|
||||
|
||||
const {relay, h} = $derived($page.params)
|
||||
const url = $derived(relay ? decodeRelay(relay) : undefined)
|
||||
@@ -41,6 +53,14 @@
|
||||
)
|
||||
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 => {
|
||||
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
|
||||
return $currentVoiceRoom
|
||||
@@ -66,9 +86,45 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{#if targetRoom}
|
||||
@@ -76,19 +132,47 @@
|
||||
in:fly={{y: 60, duration: 350}}
|
||||
out:fly={{y: 60, duration: 250}}
|
||||
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
||||
<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>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onclick={goToRoom}
|
||||
aria-label="Open room {roomName}">
|
||||
<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}
|
||||
<span class="ellipsize text-xs opacity-70">
|
||||
{roomName} / {spaceName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if $voiceState === VoiceState.Joining}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<Button
|
||||
@@ -100,16 +184,45 @@
|
||||
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
||||
<Button
|
||||
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
||||
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
||||
? 'btn-error'
|
||||
: 'btn-ghost'}"
|
||||
class={cx(
|
||||
mediaToggleClass,
|
||||
"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}>
|
||||
<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
|
||||
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"
|
||||
onclick={openAudioSettings}>
|
||||
onclick={openCallSettings}>
|
||||
<Icon icon={Settings} size={4} />
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -22,6 +22,7 @@ import {PollResponse} from "nostr-tools/kinds"
|
||||
import {
|
||||
DELETE,
|
||||
REPORT,
|
||||
MESSAGE,
|
||||
PROFILE,
|
||||
MESSAGING_RELAYS,
|
||||
RELAYS,
|
||||
@@ -122,6 +123,34 @@ export const prependParent = (
|
||||
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
|
||||
|
||||
export const broadcastUserData = async (relays: string[]) => {
|
||||
@@ -737,9 +766,10 @@ export const addSpaceMembers = async (
|
||||
pubkeys: string[],
|
||||
): Promise<string | undefined> => {
|
||||
const spaceMembers = get(deriveSpaceMembers(url))
|
||||
|
||||
const results = await Promise.all(
|
||||
pubkeys
|
||||
.filter(pubkey => !spaceMembers.includes(pubkey))
|
||||
.filter(pubkey => !spaceMembers || !spaceMembers.includes(pubkey))
|
||||
.map(pubkey =>
|
||||
manageRelay(url, {
|
||||
method: ManagementMethod.AllowPubkey,
|
||||
|
||||
+106
-69
@@ -1,5 +1,6 @@
|
||||
import {get, writable} from "svelte/store"
|
||||
import {writable} from "svelte/store"
|
||||
import {
|
||||
batch,
|
||||
call,
|
||||
uniq,
|
||||
int,
|
||||
@@ -25,7 +26,8 @@ import {
|
||||
sortEventsDesc,
|
||||
} 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 {createScroller} from "@lib/html"
|
||||
import {daysBetween} from "@lib/util"
|
||||
@@ -56,57 +58,75 @@ export const makeFeed = ({
|
||||
let backwardWindow = [at - interval, at]
|
||||
let forwardWindow = [at, at + interval]
|
||||
|
||||
const insertEvent = (event: TrustedEvent) => {
|
||||
let handled = false
|
||||
const insertIntoBuffer = (event: TrustedEvent) => {
|
||||
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)) {
|
||||
const $events = get(events)
|
||||
// Batch-insert events into the visible store with a single update
|
||||
const insertEvents = (newEvents: TrustedEvent[]) => {
|
||||
const visible: TrustedEvent[] = []
|
||||
|
||||
for (let i = 0; i < $events.length; i++) {
|
||||
if ($events[i].created_at > event.created_at) {
|
||||
events.set(insertAt(i, event, $events))
|
||||
handled = true
|
||||
break
|
||||
for (const event of newEvents) {
|
||||
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
|
||||
visible.push(event)
|
||||
} else {
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return $events
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribers = [
|
||||
on(repository, "update", ({added, removed}) => {
|
||||
if (removed.size > 0) {
|
||||
buffer = buffer.filter(e => !removed.has(e.id))
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
on(
|
||||
repository,
|
||||
"update",
|
||||
batch(150, (updates: RepositoryUpdate[]) => {
|
||||
const {added, removed} = mergeRepositoryUpdates(updates)
|
||||
|
||||
for (const event of added) {
|
||||
if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
|
||||
insertEvent(event)
|
||||
if (removed.size > 0) {
|
||||
buffer = buffer.filter(e => !removed.has(e.id))
|
||||
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) => {
|
||||
if (trackerUrl === url) {
|
||||
const event = repository.getEvent(id)
|
||||
|
||||
if (event && matchFilters(filters, event)) {
|
||||
insertEvent(event)
|
||||
insertEvents([event])
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -132,17 +152,15 @@ export const makeFeed = ({
|
||||
element,
|
||||
delay: 300,
|
||||
threshold: 5000,
|
||||
onScroll: () => {
|
||||
onScroll: async () => {
|
||||
const [since, until] = backwardWindow
|
||||
|
||||
backwardWindow = [since - interval, since]
|
||||
|
||||
for (const event of buffer.splice(0, 30)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
insertEvents(buffer.splice(0, 30))
|
||||
|
||||
if (until > now() - int(2, YEAR)) {
|
||||
loadTimeframe(since, until)
|
||||
await loadTimeframe(since, until)
|
||||
} else if (!buffer.some(e => e.created_at < at)) {
|
||||
backwardScroller.stop()
|
||||
onBackwardExhausted?.()
|
||||
@@ -155,17 +173,15 @@ export const makeFeed = ({
|
||||
reverse: true,
|
||||
delay: 300,
|
||||
threshold: 5000,
|
||||
onScroll: () => {
|
||||
onScroll: async () => {
|
||||
const [since, until] = forwardWindow
|
||||
|
||||
forwardWindow = [until, until + interval]
|
||||
|
||||
for (const event of buffer.splice(0, 30)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
insertEvents(buffer.splice(0, 30))
|
||||
|
||||
if (until < now()) {
|
||||
loadTimeframe(since, until)
|
||||
await loadTimeframe(since, until)
|
||||
} else if (!buffer.some(e => e.created_at > at)) {
|
||||
forwardScroller.stop()
|
||||
onForwardExhausted?.()
|
||||
@@ -208,40 +224,61 @@ export const makeCalendarFeed = ({
|
||||
|
||||
const events = writable(sortBy(getStart, getEventsForUrl(url, filters)))
|
||||
|
||||
const insertEvent = (event: TrustedEvent) => {
|
||||
const start = getStart(event)
|
||||
const address = getAddress(event)
|
||||
|
||||
if (isNaN(start) || isNaN(getEnd(event))) return
|
||||
// Batch-insert calendar events into the store with a single update
|
||||
const insertEvents = (newEvents: TrustedEvent[]) => {
|
||||
const valid = newEvents.filter(e => !isNaN(getStart(e)) && !isNaN(getEnd(e)))
|
||||
if (valid.length === 0) return
|
||||
|
||||
events.update($events => {
|
||||
for (let i = 0; i < $events.length; i++) {
|
||||
if ($events[i].id === event.id) return $events
|
||||
if (getStart($events[i]) > start) return insertAt(i, event, $events)
|
||||
}
|
||||
for (const event of valid) {
|
||||
const start = getStart(event)
|
||||
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 = [
|
||||
on(repository, "update", ({added, removed}) => {
|
||||
if (removed.size > 0) {
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
on(
|
||||
repository,
|
||||
"update",
|
||||
batch(150, (updates: RepositoryUpdate[]) => {
|
||||
const {added, removed} = mergeRepositoryUpdates(updates)
|
||||
|
||||
for (const event of added) {
|
||||
if (matchFilters(filters, event)) {
|
||||
insertEvent(event)
|
||||
if (removed.size > 0) {
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
const matching = added.filter(event => matchFilters(filters, event))
|
||||
|
||||
if (matching.length > 0) {
|
||||
insertEvents(matching)
|
||||
}
|
||||
}),
|
||||
),
|
||||
on(tracker, "add", (id: string, trackerUrl: string) => {
|
||||
if (trackerUrl === url) {
|
||||
const event = repository.getEvent(id)
|
||||
|
||||
if (event && matchFilters(filters, event)) {
|
||||
insertEvent(event)
|
||||
insertEvents([event])
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
+163
-102
@@ -8,7 +8,6 @@ import {
|
||||
on,
|
||||
gt,
|
||||
max,
|
||||
find,
|
||||
spec,
|
||||
call,
|
||||
first,
|
||||
@@ -330,8 +329,6 @@ if (ENABLE_ZAPS) {
|
||||
|
||||
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]
|
||||
|
||||
// Settings
|
||||
@@ -555,7 +552,7 @@ export const chatsById = call(() => {
|
||||
setTimeout(() => {
|
||||
addEvents(added)
|
||||
removeEvents(removed)
|
||||
}, 50)
|
||||
}, 200)
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -569,7 +566,7 @@ export const deriveChat = call(() => {
|
||||
return (pubkeys: string[]) => _deriveChat(makeChatId(pubkeys))
|
||||
})
|
||||
|
||||
export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
|
||||
export const chatSearch = derived(throttled(1500, chatsById), $chatsByPubkey => {
|
||||
return createSearch(
|
||||
sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())),
|
||||
{
|
||||
@@ -610,7 +607,7 @@ export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
|
||||
})
|
||||
|
||||
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()) {
|
||||
const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values())
|
||||
@@ -622,6 +619,8 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
|
||||
}
|
||||
}
|
||||
|
||||
const metaById = new Map<string, Room>()
|
||||
|
||||
for (const event of metaEvents) {
|
||||
const meta = tryCatch(() => readRoomMeta(event))
|
||||
|
||||
@@ -629,22 +628,14 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
|
||||
continue
|
||||
}
|
||||
|
||||
let metaById = metaByIdByUrl.get(url)
|
||||
if (!metaById) {
|
||||
metaById = new Map()
|
||||
metaByIdByUrl.set(url, metaById)
|
||||
}
|
||||
|
||||
const id = makeRoomId(url, meta.h)
|
||||
|
||||
metaById.set(id, {...meta, url, id})
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Map<string, Room[]>()
|
||||
|
||||
for (const [url, metaById] of metaByIdByUrl.entries()) {
|
||||
result.set(url, Array.from(metaById.values()))
|
||||
if (metaById.size > 0) {
|
||||
result.set(url, Array.from(metaById.values()))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -818,37 +809,18 @@ export const deriveOtherRooms = (url: string) =>
|
||||
// Space/room memberships
|
||||
|
||||
export const deriveSpaceMembers = (url: string) =>
|
||||
derived(
|
||||
deriveRelaySignedEvents(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]}]),
|
||||
$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)
|
||||
},
|
||||
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) =>
|
||||
uniq(getTagValues("member", event?.tags ?? [])),
|
||||
)
|
||||
|
||||
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 = {
|
||||
pubkey: string
|
||||
reason: string
|
||||
@@ -867,41 +839,6 @@ export const deriveSpaceBannedPubkeyItems = (url: string) => {
|
||||
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) => {
|
||||
const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}]
|
||||
|
||||
@@ -916,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)
|
||||
// const pendingJoins: TrustedEvent[] = []
|
||||
|
||||
@@ -923,7 +896,7 @@ export const deriveSpaceActionItems = (url: string) =>
|
||||
derived(
|
||||
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 => {
|
||||
@@ -936,19 +909,50 @@ export const deriveSpaceActionItems = (url: string) =>
|
||||
for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
|
||||
if (!h) continue
|
||||
|
||||
const roomJoins = roomEvents.filter(spec({kind: ROOM_JOIN}))
|
||||
const roomLeaves = roomEvents.filter(spec({kind: ROOM_LEAVE}))
|
||||
const roomMembersEvent = roomEvents.find(spec({kind: ROOM_MEMBERS}))
|
||||
const roomMembers = getTagValues("p", roomMembersEvent?.tags ?? [])
|
||||
const roomJoins: TrustedEvent[] = []
|
||||
const roomLeaves: TrustedEvent[] = []
|
||||
const roomMembershipEvents: TrustedEvent[] = []
|
||||
|
||||
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(
|
||||
...removeUndefined(
|
||||
Array.from(groupBy(e => e.pubkey, roomJoins).values())
|
||||
.map(sortEventsDesc)
|
||||
.map(first),
|
||||
Array.from(groupBy(e => e.pubkey, roomJoins).values()).map(events =>
|
||||
first(sortEventsDesc(events)),
|
||||
),
|
||||
).filter(({pubkey, created_at}) => {
|
||||
if (roomMembers.includes(pubkey)) return false
|
||||
if (gt(roomMembersEvent?.created_at, created_at)) return false
|
||||
if (roomMembers.has(pubkey)) 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
|
||||
|
||||
return true
|
||||
@@ -981,19 +985,49 @@ export const deriveUserIsSpaceAdmin = memoize((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(
|
||||
[
|
||||
pubkey,
|
||||
deriveSpaceMembers(url),
|
||||
deriveEventsForUrl(url, filters),
|
||||
deriveRelaySignedEvents(url, memberListFilters),
|
||||
deriveRelaySignedEvents(url, userEventFilters),
|
||||
deriveEventsForUrl(url, [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]),
|
||||
deriveUserIsSpaceAdmin(url),
|
||||
],
|
||||
([$pubkey, $members, $events, $isAdmin]) => {
|
||||
const isMember = $members.includes($pubkey!) || $isAdmin
|
||||
([$pubkey, $memberListEvents, $userAddRemoveEvents, $joinLeaveEvents, $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) {
|
||||
continue
|
||||
}
|
||||
@@ -1019,19 +1053,46 @@ export const deriveUserIsRoomAdmin = (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(
|
||||
[
|
||||
pubkey,
|
||||
deriveRoomMembers(url, h),
|
||||
deriveEventsForUrl(url, filters),
|
||||
deriveEventsForUrl(url, userEventFilters),
|
||||
deriveEventsForUrl(url, joinLeaveFilters),
|
||||
deriveUserIsRoomAdmin(url, h),
|
||||
],
|
||||
([$pubkey, $members, $events, $isAdmin]) => {
|
||||
const isMember = $members.includes($pubkey!) || $isAdmin
|
||||
([$pubkey, $memberList, $userAddRemoveEvents, $joinLeaveEvents, $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) {
|
||||
continue
|
||||
}
|
||||
|
||||
+21
-26
@@ -13,10 +13,13 @@ import {
|
||||
ROOM_MEMBERS,
|
||||
ROOM_ADD_MEMBER,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOM_JOIN,
|
||||
ROOM_LEAVE,
|
||||
ROOM_CREATE_PERMISSION,
|
||||
RELAY_MEMBERS,
|
||||
RELAY_ADD_MEMBER,
|
||||
RELAY_REMOVE_MEMBER,
|
||||
MESSAGE,
|
||||
isSignedEvent,
|
||||
unionFilters,
|
||||
getTagValue,
|
||||
@@ -41,7 +44,6 @@ import {
|
||||
} from "@welshman/app"
|
||||
import {
|
||||
REACTION_KINDS,
|
||||
MESSAGE_KINDS,
|
||||
CONTENT_KINDS,
|
||||
INDEXER_RELAYS,
|
||||
loadSettings,
|
||||
@@ -55,7 +57,7 @@ import {
|
||||
loadFeedsForPubkey,
|
||||
} from "@app/core/state"
|
||||
import {hasBlossomSupport} from "@app/core/commands"
|
||||
import {LIVEKIT_PARTICIPANTS} from "@app/voice"
|
||||
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
|
||||
|
||||
// Utils
|
||||
|
||||
@@ -266,7 +268,7 @@ const syncUserData = () => {
|
||||
|
||||
// Spaces
|
||||
|
||||
const syncSpace = (url: string, rooms: string[]) => {
|
||||
const syncSpace = (url: string) => {
|
||||
const since = ago(WEEK)
|
||||
const seen = new Set<string>()
|
||||
const controller = new AbortController()
|
||||
@@ -278,27 +280,28 @@ const syncSpace = (url: string, rooms: string[]) => {
|
||||
url,
|
||||
signal: controller.signal,
|
||||
filters: [
|
||||
{kinds: MESSAGE_KINDS, since, "#h": [room]},
|
||||
{kinds: [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS], "#d": [room]},
|
||||
{kinds: [MESSAGE, ...CONTENT_KINDS], since, "#h": [room]},
|
||||
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
|
||||
{
|
||||
kinds: [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE],
|
||||
"#h": [room],
|
||||
},
|
||||
{kinds: [PollResponse], since},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const room of rooms) {
|
||||
pullRoomContent(room)
|
||||
}
|
||||
|
||||
const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]
|
||||
const relayKinds = [RELAY_MEMBERS]
|
||||
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({
|
||||
url,
|
||||
signal: controller.signal,
|
||||
filters: [
|
||||
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
|
||||
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
|
||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||
{kinds: [PollResponse], since},
|
||||
],
|
||||
@@ -321,36 +324,28 @@ const syncSpace = (url: string, rooms: string[]) => {
|
||||
const syncSpaces = () => {
|
||||
const store = merged([userGroupList, page])
|
||||
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
||||
const roomsByUrl = new Map<string, string>()
|
||||
|
||||
const unsubscribe = store.subscribe(([$userGroupList, $page]) => {
|
||||
const urls = new Set(getSpaceUrlsFromGroupList($userGroupList))
|
||||
const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined
|
||||
|
||||
if ($page.params.relay) {
|
||||
urls.add(decodeRelay($page.params.relay))
|
||||
if (currentUrl) {
|
||||
urls.add(currentUrl)
|
||||
}
|
||||
|
||||
// Stop syncing removed spaces
|
||||
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
||||
if (!urls.has(url)) {
|
||||
unsubscribersByUrl.delete(url)
|
||||
roomsByUrl.delete(url)
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// Start or restart syncing for each space
|
||||
// Start syncing for new spaces
|
||||
for (const url of urls) {
|
||||
const rooms = getSpaceRoomsFromGroupList(url, $userGroupList)
|
||||
const roomsKey = rooms.join(",")
|
||||
|
||||
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))
|
||||
if (!unsubscribersByUrl.has(url)) {
|
||||
unsubscribersByUrl.set(url, syncSpace(url))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {deriveRoom, splitRoomId} from "@app/core/state"
|
||||
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
|
||||
import {splitRoomId} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
@@ -8,11 +9,9 @@
|
||||
|
||||
const {value}: Props = $props()
|
||||
const [url = "", h = ""] = splitRoomId(value)
|
||||
const room = deriveRoom(url, h)
|
||||
|
||||
const label = $derived(`~${displayRelayUrl(url)} / ${$room.name || h}`)
|
||||
</script>
|
||||
|
||||
<div class="max-w-full overflow-hidden text-ellipsis text-base font-semibold">
|
||||
{label}
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -26,6 +26,7 @@ import {makeMentionNodeView} from "@app/editor/MentionNodeView"
|
||||
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 {
|
||||
deriveSpaceMembers,
|
||||
@@ -78,7 +79,7 @@ export const makeEditor = async ({
|
||||
getValue: (profile: PublishedProfile) => profile.event.pubkey,
|
||||
sortFn: ({score = 1, item}) => {
|
||||
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
|
||||
},
|
||||
@@ -199,6 +200,7 @@ export const makeEditor = async ({
|
||||
},
|
||||
},
|
||||
}),
|
||||
NativeClipboardPasteExtension,
|
||||
],
|
||||
onUpdate({editor}) {
|
||||
wordCount?.set(editor.storage.wordCount.words)
|
||||
|
||||
+33
-1
@@ -1,4 +1,4 @@
|
||||
import {writable} from "svelte/store"
|
||||
import {get, writable} from "svelte/store"
|
||||
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
||||
import {Nip46Broker} from "@welshman/signer"
|
||||
import {makeSecret} from "@welshman/util"
|
||||
@@ -11,6 +11,22 @@ import {
|
||||
} from "@app/core/state"
|
||||
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 {
|
||||
url = 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() {
|
||||
this.broker.cleanup()
|
||||
this.abortController.abort()
|
||||
|
||||
@@ -5,10 +5,10 @@ import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
|
||||
import {assoc, prop, first, identity, groupBy, now} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
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 {
|
||||
MESSAGE_KINDS,
|
||||
CONTENT_KINDS,
|
||||
notificationSettings,
|
||||
chatsById,
|
||||
userGroupList,
|
||||
@@ -85,7 +85,7 @@ export const allNotifications = derived(
|
||||
deriveEventsByIdByUrl({
|
||||
tracker,
|
||||
repository,
|
||||
filters: [{kinds: MESSAGE_KINDS}, makeCommentFilter(MESSAGE_KINDS)],
|
||||
filters: [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)],
|
||||
}),
|
||||
],
|
||||
identity,
|
||||
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
getRelaysFromList,
|
||||
getTagValue,
|
||||
matchFilters,
|
||||
MESSAGE,
|
||||
type Filter,
|
||||
type TrustedEvent,
|
||||
} from "@welshman/util"
|
||||
import {
|
||||
DM_KINDS,
|
||||
CONTENT_KINDS,
|
||||
MESSAGE_KINDS,
|
||||
notificationSettings,
|
||||
pushState,
|
||||
shouldNotify,
|
||||
@@ -45,7 +45,10 @@ export type PushPermissionResult = {
|
||||
}
|
||||
|
||||
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 subscribers: Subscriber<TrustedEvent>[] = []
|
||||
|
||||
@@ -158,7 +161,7 @@ export const syncRelaySubscriptions = (
|
||||
userSettingsValues,
|
||||
]).subscribe(
|
||||
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) {
|
||||
const {notify = true, exceptions = []} = alerts.find(spec({url})) || {}
|
||||
|
||||
@@ -63,11 +63,11 @@ export const goToSpace = async (url: string) => {
|
||||
const prevPath = lastPageBySpaceUrl.get(encodeRelay(url))
|
||||
|
||||
if (prevPath && prevPath !== makeSpacePath(url)) {
|
||||
goto(prevPath)
|
||||
goto(prevPath, {replaceState: true})
|
||||
} else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) {
|
||||
goto(makeSpacePath(url, "recent"))
|
||||
goto(makeSpacePath(url, "recent"), {replaceState: true})
|
||||
} else {
|
||||
goto(makeSpacePath(url))
|
||||
goto(makeSpacePath(url), {replaceState: true})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-4
@@ -48,6 +48,18 @@ import {
|
||||
import type {Unsubscriber} from "svelte/store"
|
||||
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 = {
|
||||
meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS],
|
||||
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()))
|
||||
}
|
||||
|
||||
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 table = db.table<RelayStats>("relayStats")
|
||||
|
||||
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 () => {
|
||||
@@ -214,7 +227,7 @@ const initHandles = async () => {
|
||||
|
||||
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 () => {
|
||||
@@ -222,7 +235,7 @@ const initZappers = async () => {
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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 |
@@ -14,9 +14,10 @@
|
||||
style?: string
|
||||
disabled?: boolean
|
||||
"data-tip"?: string
|
||||
"aria-pressed"?: boolean
|
||||
} = $props()
|
||||
|
||||
const className = $derived(`text-left ${restProps.class}`)
|
||||
const className = $derived(`text-left cursor-pointer ${restProps.class}`)
|
||||
|
||||
const onClick = (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import Close from "@assets/icons/close.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {clearModals} from "@app/util/modal"
|
||||
|
||||
type Props = {
|
||||
onClose?: any
|
||||
@@ -56,7 +55,7 @@
|
||||
<div class={wrapperClass}>
|
||||
<div class={innerClass} transition:fly>
|
||||
{#if !noEscape}
|
||||
<Button class={buttonClass} onclick={clearModals}>
|
||||
<Button class={buttonClass} onclick={onClose}>
|
||||
<Icon icon={Close} size={6} />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
|
||||
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>
|
||||
|
||||
<div {...props} bind:this={element} data-component="PageContent" class={className}>
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
|
||||
interface Props {
|
||||
class?: string
|
||||
visible?: boolean
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
const {children, ...props}: Props = $props()
|
||||
const {children, visible = false, ...props}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cx(
|
||||
"mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav 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,
|
||||
)}>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -78,6 +78,21 @@
|
||||
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}`
|
||||
goto(target, {replaceState: false, noScroll: false})
|
||||
})
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
import Dialog from "@lib/components/Dialog.svelte"
|
||||
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
|
||||
|
||||
const close = () => goto("/home")
|
||||
|
||||
const children = {
|
||||
component: SpaceInviteAccept,
|
||||
props: {
|
||||
invite: $page.url.href,
|
||||
back: () => goto("/home"),
|
||||
back: close,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog {children} />
|
||||
<Dialog {children} onClose={close} />
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<Page>
|
||||
<ContentSearch>
|
||||
{#snippet input()}
|
||||
<label class="row-2 input input-bordered">
|
||||
<label class="row-2 input input-bordered w-full">
|
||||
<Icon icon={Magnifier} />
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
|
||||
@@ -172,9 +172,9 @@
|
||||
</strong>
|
||||
<Button onclick={() => (showAdvanced = !showAdvanced)}>
|
||||
{#if showAdvanced}
|
||||
<Icon icon={AltArrowDown} />
|
||||
{:else}
|
||||
<Icon icon={AltArrowUp} />
|
||||
{:else}
|
||||
<Icon icon={AltArrowDown} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -58,14 +58,14 @@
|
||||
<RelaySettingsItem
|
||||
icon={Inbox}
|
||||
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}
|
||||
addRelay={addReadRelay}
|
||||
removeRelay={removeReadRelay} />
|
||||
<RelaySettingsItem
|
||||
icon={Plane}
|
||||
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}
|
||||
addRelay={addWriteRelay}
|
||||
removeRelay={removeWriteRelay} />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {onMount, tick} from "svelte"
|
||||
import {flip} from "svelte/animate"
|
||||
import {cubicOut} from "svelte/easing"
|
||||
import {derived as _derived} from "svelte/store"
|
||||
import {dec, insertAt, removeAt, sleep} from "@welshman/lib"
|
||||
import type {RelayProfile} from "@welshman/util"
|
||||
@@ -7,6 +9,7 @@
|
||||
import {relays, createSearch} from "@welshman/app"
|
||||
import {createScroller} from "@lib/html"
|
||||
import {fly} from "@lib/transition"
|
||||
import DragHandle from "@assets/icons/drag-handle.svg?dataurl"
|
||||
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
@@ -98,6 +101,8 @@
|
||||
const onDragStart = (e: DragEvent, url: string) => {
|
||||
draggedUrl = url
|
||||
dragStartOrder = [...orderedSpaceUrls]
|
||||
lastDragTarget = undefined
|
||||
didDrop = false
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
@@ -105,15 +110,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent, targetUrl: string) => {
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const onDragEnter = (e: DragEvent, targetUrl: string) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (lastDragTarget === targetUrl) return
|
||||
|
||||
lastDragTarget = targetUrl
|
||||
reorderSpaceUrls(targetUrl)
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent, targetUrl: string) => {
|
||||
e.preventDefault()
|
||||
reorderSpaceUrls(targetUrl)
|
||||
didDrop = true
|
||||
draggedUrl = undefined
|
||||
lastDragTarget = undefined
|
||||
|
||||
if (dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) {
|
||||
void setSpaceMembershipOrder(orderedSpaceUrls).catch(console.error)
|
||||
@@ -123,8 +138,14 @@
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
if (!didDrop && dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) {
|
||||
orderedSpaceUrls = dragStartOrder
|
||||
}
|
||||
|
||||
draggedUrl = undefined
|
||||
dragStartOrder = undefined
|
||||
lastDragTarget = undefined
|
||||
didDrop = false
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -143,6 +164,8 @@
|
||||
let orderedSpaceUrls = $state<string[]>([])
|
||||
let draggedUrl = $state<string | undefined>()
|
||||
let dragStartOrder = $state<string[] | undefined>()
|
||||
let lastDragTarget = $state<string | undefined>()
|
||||
let didDrop = $state(false)
|
||||
|
||||
const openSearch = () => {
|
||||
showSearch = true
|
||||
@@ -247,17 +270,25 @@
|
||||
<Divider>Your spaces</Divider>
|
||||
{#each filteredUserUrls as url (url)}
|
||||
<div
|
||||
class:opacity-60={draggedUrl === url}
|
||||
animate:flip={{duration: 300, easing: cubicOut}}
|
||||
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
|
||||
draggable="true"
|
||||
role="listitem"
|
||||
ondragstart={e => onDragStart(e, url)}
|
||||
ondragover={e => onDragOver(e, url)}
|
||||
ondragover={onDragOver}
|
||||
ondragenter={e => onDragEnter(e, url)}
|
||||
ondrop={e => onDrop(e, url)}
|
||||
ondragend={onDragEnd}>
|
||||
<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)}>
|
||||
<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))}
|
||||
<div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div>
|
||||
{/if}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import SpaceMenu from "@app/components/SpaceMenu.svelte"
|
||||
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
const md = parseInt(theme.screens.md, 10)
|
||||
const md = parseFloat(theme.screens.md) * 16
|
||||
|
||||
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">
|
||||
<PrimaryNavSpaces />
|
||||
</div>
|
||||
<SecondaryNav class="flex! w-auto! grow pb-16">
|
||||
<SecondaryNav visible class="w-auto grow pb-16">
|
||||
<SpaceMenu {url} />
|
||||
</SecondaryNav>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
|
||||
type Props = {
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
const {children}: Props = $props()
|
||||
</script>
|
||||
|
||||
{#key $page.url.searchParams.get("at")}
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
{/key}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
||||
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
||||
import cx from "classnames"
|
||||
import {slide, fade, fly} from "@lib/transition"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
@@ -36,14 +37,15 @@
|
||||
deriveRoom,
|
||||
deriveUserRoomMembershipStatus,
|
||||
getRoomType,
|
||||
MESSAGE_KINDS,
|
||||
MembershipStatus,
|
||||
PROTECTED,
|
||||
RoomType,
|
||||
userSettingsValues,
|
||||
} from "@app/core/state"
|
||||
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 {popKey} from "@lib/implicit"
|
||||
import {checked} from "@app/util/notifications"
|
||||
@@ -56,9 +58,53 @@
|
||||
const url = decodeRelay(relay)
|
||||
const room = deriveRoom(url, h)
|
||||
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 membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
||||
const shouldVirtualize = $derived(isNaN(at))
|
||||
|
||||
const showRoomDetail = () => pushModal(RoomDetail, {url, h})
|
||||
|
||||
@@ -302,11 +348,15 @@
|
||||
|
||||
elements.reverse()
|
||||
|
||||
requestAnimationFrame(manageScrollPosition)
|
||||
|
||||
return elements
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (elements.length > 0) {
|
||||
requestAnimationFrame(manageScrollPosition)
|
||||
}
|
||||
})
|
||||
|
||||
const start = () => {
|
||||
cleanup?.()
|
||||
|
||||
@@ -314,7 +364,7 @@
|
||||
url,
|
||||
at: at || now(),
|
||||
element: element!,
|
||||
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER], "#h": [h]}],
|
||||
filters: [{kinds: [MESSAGE, ROOM_ADD_MEMBER], "#h": [h]}],
|
||||
onBackwardExhausted: () => {
|
||||
loadingBackward = false
|
||||
},
|
||||
@@ -364,127 +414,184 @@
|
||||
{/snippet}
|
||||
</SpaceBar>
|
||||
|
||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
||||
{#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 } (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>
|
||||
<div
|
||||
class={cx(
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split && "md:flex-row",
|
||||
)}>
|
||||
{#if voiceConnectedHere}
|
||||
<VideoCallContent
|
||||
layout={$videoCallLayout}
|
||||
{url}
|
||||
{h}
|
||||
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
|
||||
{/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 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}
|
||||
<div
|
||||
class={cx(
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col",
|
||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
|
||||
)}>
|
||||
{#if isVoiceRoom && $voiceState === VoiceState.Connected}
|
||||
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
|
||||
<div class="hide-on-keyboard shrink-0 p-2 md:hidden">
|
||||
<VoiceWidget />
|
||||
|
||||
<PageContent
|
||||
bind:element
|
||||
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 if shouldVirtualize}
|
||||
{@const event = value as TrustedEvent}
|
||||
{#if event.kind === ROOM_ADD_MEMBER}
|
||||
<RoomItemAddMember {url} {event} />
|
||||
{:else}
|
||||
<div class="cv">
|
||||
<RoomItem
|
||||
{url}
|
||||
{event}
|
||||
{replyTo}
|
||||
{showPubkey}
|
||||
{addSpaceBelow}
|
||||
canEdit={canEditEvent}
|
||||
onEdit={onEditEvent} />
|
||||
</div>
|
||||
{/if}
|
||||
{: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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showScrollButton}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
import RoomCompose from "@app/components/RoomCompose.svelte"
|
||||
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.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 {checked} from "@app/util/notifications"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
@@ -37,6 +37,7 @@
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
||||
const shouldVirtualize = $derived(isNaN(at))
|
||||
|
||||
const replyTo = (event: TrustedEvent) => {
|
||||
parent = event
|
||||
@@ -240,11 +241,15 @@
|
||||
|
||||
elements.reverse()
|
||||
|
||||
requestAnimationFrame(manageScrollPosition)
|
||||
|
||||
return elements
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (elements.length > 0) {
|
||||
requestAnimationFrame(manageScrollPosition)
|
||||
}
|
||||
})
|
||||
|
||||
const start = () => {
|
||||
cleanup?.()
|
||||
|
||||
@@ -252,7 +257,7 @@
|
||||
url,
|
||||
at: at || now(),
|
||||
element: element!,
|
||||
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER]}],
|
||||
filters: [{kinds: [MESSAGE, RELAY_ADD_MEMBER]}],
|
||||
onBackwardExhausted: () => {
|
||||
loadingBackward = false
|
||||
},
|
||||
@@ -305,7 +310,7 @@
|
||||
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
||||
</p>
|
||||
{/if}
|
||||
{#each elements as { type, id, value, showPubkey, addSpaceBelow } (id)}
|
||||
{#each elements as { type, id, value, showPubkey, addSpaceBelow }, i (id)}
|
||||
{#if type === "new-messages"}
|
||||
<div
|
||||
{id}
|
||||
@@ -317,8 +322,24 @@
|
||||
</div>
|
||||
{:else if type === "date"}
|
||||
<Divider>{value}</Divider>
|
||||
{:else if shouldVirtualize}
|
||||
{@const event = value as TrustedEvent}
|
||||
{#if event.kind === RELAY_ADD_MEMBER}
|
||||
<RoomItemAddMember {url} {event} />
|
||||
{:else}
|
||||
<div>
|
||||
<RoomItem
|
||||
{url}
|
||||
{event}
|
||||
{replyTo}
|
||||
{showPubkey}
|
||||
canEdit={canEditEvent}
|
||||
onEdit={onEditEvent}
|
||||
{addSpaceBelow} />
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const event = $state.snapshot(value as TrustedEvent)}
|
||||
{@const event = value as TrustedEvent}
|
||||
{#if event.kind === RELAY_ADD_MEMBER}
|
||||
<RoomItemAddMember {url} {event} />
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user