Compare commits

..

5 Commits

Author SHA1 Message Date
mplorentz 7f0770e901 Change screen sharing icon 2026-03-26 11:44:01 -04:00
mplorentz c01b0287f5 Improve pinned video layout 2026-03-26 11:42:08 -04:00
mplorentz 112f5517a7 Add a button to spotlight a video feed 2026-03-26 11:36:52 -04:00
mplorentz eb8117cc95 Add basic screen sharing 2026-03-26 10:59:50 -04:00
mplorentz edceb92acc add video to livekit calls 2026-03-26 10:49:14 -04:00
83 changed files with 2004 additions and 3124 deletions
+1 -2
View File
@@ -15,8 +15,7 @@ VITE_PUSH_BRIDGE=wss://npb.coracle.social/
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,nostr-01.uid.ovh,relay.keychat.io,relay.0xchat.com
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY=
-1
View File
@@ -169,7 +169,6 @@ src/
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
**Human-First Simplicity (Jon Staab Style):**
+1 -15
View File
@@ -1,23 +1,9 @@
# Changelog
# 1.7.1
* Fix pomade registration fallback in case of offline signer
# 1.7.0
# Current
* Enable email/password login
* Add up/edit to direct messages
* Fix a number of UI bugs
* Improve navigation on mobile
* Improve performance and syncing reliability
* Add proof of work to DMs
* Detect blossom support using supported_nips
* Improve notification badges
* Add voice rooms (@mplorentz)
* Re-design relay onboarding and settings
* Add android fallback for push notifications
* Fix file uploads on android
# 1.6.5
+2 -7
View File
@@ -1,5 +1,4 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
namespace = "social.flotilla"
@@ -8,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 43
versionName "1.7.1"
versionCode 41
versionName "1.6.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -36,10 +35,6 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.work:work-runtime:2.10.3"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation "fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
-1
View File
@@ -9,7 +9,6 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':aparajita-capacitor-secure-storage')
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
@@ -1,15 +1,5 @@
package social.flotilla;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
import social.flotilla.notifications.AndroidPushFallbackPlugin;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(AndroidPushFallbackPlugin.class);
super.onCreate(savedInstanceState);
}
}
public class MainActivity extends BridgeActivity {}
@@ -1,99 +0,0 @@
package social.flotilla.notifications
import android.content.Context
import android.content.SharedPreferences
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.util.concurrent.TimeUnit
@CapacitorPlugin(name = "AndroidPushFallback")
class AndroidPushFallbackPlugin : Plugin() {
companion object {
const val PREFS_NAME = "CapacitorStorage"
const val KEY_STATE = "androidPushFallback.state"
const val UNIQUE_PERIODIC_WORK = "androidPushFallback.periodic"
const val UNIQUE_IMMEDIATE_WORK = "androidPushFallback.immediate"
}
private fun getPrefs(): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
@PluginMethod
fun syncState(call: PluginCall) {
val state: JSObject? = call.getObject("state")
if (state != null) {
getPrefs().edit().putString(KEY_STATE, state.toString()).apply()
if (isEnabled(state.toString())) {
scheduleWork()
} else {
cancelWork()
}
}
call.resolve()
}
private fun isEnabled(rawState: String?): Boolean {
if (rawState == null || rawState.isEmpty()) {
return false
}
return try {
val state = JSONObject(rawState)
val subscriptions: JSONArray? = state.optJSONArray("subscriptions")
subscriptions != null && subscriptions.length() > 0
} catch (_: JSONException) {
false
}
}
private fun scheduleWork() {
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
val workManager = WorkManager.getInstance(context)
val periodic = PeriodicWorkRequest.Builder(
AndroidPushFallbackWorker::class.java,
15,
TimeUnit.MINUTES,
).setConstraints(constraints).build()
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
UNIQUE_PERIODIC_WORK,
ExistingPeriodicWorkPolicy.UPDATE,
periodic,
)
workManager.enqueueUniqueWork(
UNIQUE_IMMEDIATE_WORK,
ExistingWorkPolicy.REPLACE,
immediate,
)
}
private fun cancelWork() {
val workManager = WorkManager.getInstance(context)
workManager.cancelUniqueWork(UNIQUE_IMMEDIATE_WORK)
workManager.cancelUniqueWork(UNIQUE_PERIODIC_WORK)
}
}
@@ -1,862 +0,0 @@
package social.flotilla.notifications
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.util.Log
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.app.ActivityManager
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import fr.acinq.secp256k1.Secp256k1
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONArray
import org.json.JSONObject
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.KEY_STATE
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.PREFS_NAME
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Arrays
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import android.util.Base64
class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
companion object {
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 REJECTED = "__REJECTED__"
private const val KIND_RELAY_AUTH = 22242
private const val KIND_NIP46_RPC = 24133
private val SECP = Secp256k1.get()
}
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val client: OkHttpClient = OkHttpClient.Builder().build()
// ---- Socket pool ----
// Opens each relay URL at most once; caller must invoke closeAll() when done.
private inner class SocketPool {
private val sockets = ConcurrentHashMap<String, WebSocket>()
fun open(url: String, listener: WebSocketListener): WebSocket =
sockets.getOrPut(url) {
client.newWebSocket(Request.Builder().url(url).build(), listener)
}
fun closeAll() {
for ((_, ws) in sockets) ws.close(1000, "done")
sockets.clear()
}
}
override fun doWork(): Result {
if (isAppInForeground()) {
return Result.success()
}
val pool = SocketPool()
try {
val rawState = prefs.getString(KEY_STATE, "") ?: ""
if (rawState.isEmpty()) return Result.success()
val state = JSONObject(rawState)
val sessionInfo = getSessionInfo(state)
val subscriptions = parseSubscriptions(state)
if (subscriptions.isEmpty()) return Result.success()
val activeSince = state.optLong("activeSince", 0L)
val seen = mutableSetOf<String>()
var latestPair: Pair<String, JSONObject>? = null
for (sub in subscriptions) {
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
val since = maxOf(prefs.getLong(cursorKey, 0L), activeSince)
val result = pollRelay(sub, since, sessionInfo, pool)
if (result.lastCursor > prefs.getLong(cursorKey, 0L)) {
prefs.edit().putLong(cursorKey, result.lastCursor).apply()
}
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)
}
}
}
}
if (latestPair != null) {
val (relay, event) = latestPair!!
postNotification(relay, event)
}
return Result.success()
} catch (e: Exception) {
Log.e(TAG, "Worker failed", e)
return Result.success()
} finally {
pool.closeAll()
client.dispatcher.executorService.shutdown()
}
}
private fun isAppInForeground(): Boolean {
val am = applicationContext.getSystemService(ActivityManager::class.java) ?: return false
val tasks = am.getRunningAppProcesses() ?: return false
val pkg = applicationContext.packageName
return tasks.any { it.processName == pkg && it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND }
}
private fun getSessionInfo(state: JSONObject): SessionInfo {
val session = state.optJSONObject("session") ?: return SessionInfo("anonymous", "", JSONObject())
return SessionInfo(
session.optString("method", "anonymous"),
session.optString("pubkey", ""),
session,
)
}
private fun parseSubscriptions(state: JSONObject): List<Subscription> {
val result = mutableListOf<Subscription>()
val arr = state.optJSONArray("subscriptions") ?: return result
for (i in 0 until arr.length()) {
val item = arr.optJSONObject(i) ?: continue
val relay = item.optString("relay", "").trim()
if (!relay.startsWith("wss://") && !relay.startsWith("ws://")) continue
val filters = item.optJSONArray("filters")
if (filters == null || filters.length() == 0) continue
val key = item.optString("key", "").trim()
result.add(Subscription(relay, key, filters, item.optJSONArray("ignore")))
}
return result
}
private fun pollRelay(sub: Subscription, since: Long, sessionInfo: SessionInfo, pool: SocketPool): RelayResult {
val result = RelayResult()
val latch = CountDownLatch(1)
val listener = RelayListener(sub, since, sessionInfo, result, latch, pool)
pool.open(sub.relay, listener)
if (!latch.await(SOCKET_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
Log.d(TAG, "Relay ${sub.relay} timed out")
}
return result
}
private fun postNotification(relay: String, event: JSONObject) {
val context = applicationContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context.getSystemService(NotificationManager::class.java)
if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(CHANNEL_ID, "Fallback Notifications", NotificationManager.IMPORTANCE_DEFAULT)
channel.description = "Notifications delivered by Android background fallback"
manager.createNotificationChannel(channel)
}
}
val id = event.optString("id", "")
val encodedRelay = Uri.encode(relay)
val url = "https://app.flotilla.social/?relay=$encodedRelay&id=$id"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
intent.setPackage(context.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val body = "New activity"
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle("Flotilla")
.setContentText(body)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.build()
NotificationManagerCompat.from(context).notify(1, notification)
}
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
val kinds = filter.optJSONArray("kinds")
if (kinds != null && kinds.length() > 0) {
val kind = event.optInt("kind", -1)
var found = false
for (i in 0 until kinds.length()) {
if (kinds.optInt(i, -1) == kind) { found = true; break }
}
if (!found) return false
}
val tags = event.optJSONArray("tags")
val iter = filter.keys()
while (iter.hasNext()) {
val key = iter.next()
if (!key.startsWith("#")) continue
val tagName = key.substring(1)
val allowed = filter.optJSONArray(key) ?: continue
if (allowed.length() == 0) continue
val allowedValues = mutableSetOf<String>()
for (i in 0 until allowed.length()) {
val v = allowed.optString(i, "")
if (v.isNotEmpty()) allowedValues.add(v)
}
var matched = false
if (tags != null) {
for (i in 0 until tags.length()) {
val tag = tags.optJSONArray(i) ?: continue
if (tag.optString(0, "") == tagName && allowedValues.contains(tag.optString(1, ""))) {
matched = true; break
}
}
}
if (!matched) return false
}
return true
}
// ---- Crypto helpers ----
private fun computeEventId(event: JSONObject): String {
return try {
val serialized = JSONArray()
serialized.put(0)
serialized.put(event.optString("pubkey", ""))
serialized.put(event.optLong("created_at", 0))
serialized.put(event.optInt("kind", 0))
serialized.put(event.optJSONArray("tags") ?: JSONArray())
serialized.put(event.optString("content", ""))
// JSONObject escapes forward slashes (/ -> \/), but NIP-01 event ID hashing
// requires unescaped slashes. Replace them before hashing.
val serializedStr = serialized.toString().replace("\\/", "/")
bytesToHex(sha256(serializedStr.toByteArray(StandardCharsets.UTF_8)))
} catch (_: Exception) {
""
}
}
private fun deriveXOnlyPubkey(secretHex: String): String {
val secret = hexToBytes(secretHex)
if (secret.size != 32 || !SECP.secKeyVerify(secret)) return ""
val pubkey65 = try { SECP.pubkeyCreate(secret) } catch (_: Exception) { return "" }
if (pubkey65.size != 65) return ""
return bytesToHex(Arrays.copyOfRange(pubkey65, 1, 33))
}
private fun schnorrSign(secretHex: String, messageHex: String): String {
val sk = hexToBytes(secretHex)
val msg = hexToBytes(messageHex)
if (sk.size != 32 || msg.size != 32 || !SECP.secKeyVerify(sk)) return ""
val aux = ByteArray(32).also { SecureRandom().nextBytes(it) }
val sig = try { SECP.signSchnorr(msg, sk, aux) } catch (_: Exception) { return "" }
if (sig.size != 64) return ""
return bytesToHex(sig)
}
private fun sha256(input: ByteArray): ByteArray =
try { MessageDigest.getInstance("SHA-256").digest(input) } catch (_: Exception) { ByteArray(32) }
private fun hexToBytes(hex: String?): ByteArray {
var s = hex?.trim()?.lowercase() ?: ""
if (s.startsWith("0x")) s = s.substring(2)
if (s.length % 2 == 1) s = "0$s"
val bytes = ByteArray(s.length / 2)
var i = 0
while (i < s.length) {
val hi = Character.digit(s[i], 16)
val lo = Character.digit(s[i + 1], 16)
if (hi < 0 || lo < 0) return ByteArray(0)
bytes[i / 2] = ((hi shl 4) + lo).toByte()
i += 2
}
return bytes
}
private fun bytesToHex(bytes: ByteArray): String {
val hex = "0123456789abcdef".toCharArray()
val chars = CharArray(bytes.size * 2)
for (i in bytes.indices) {
val v = bytes[i].toInt() and 0xFF
chars[i * 2] = hex[v ushr 4]
chars[i * 2 + 1] = hex[v and 0x0F]
}
return String(chars)
}
// ---- NIP-44 encryption (v2: ECDH + HKDF + ChaCha20 + HMAC-SHA256) ----
private fun nip44ConversationKey(clientSecret: String, theirPubkey: String): ByteArray {
val sk = hexToBytes(clientSecret)
val pk = hexToBytes("02$theirPubkey")
if (sk.size != 32 || pk.size != 33) return ByteArray(0)
val shared = try { SECP.pubKeyTweakMul(pk, sk) } catch (_: Exception) { return ByteArray(0) }
if (shared.size != 65) return ByteArray(0)
val sharedX = Arrays.copyOfRange(shared, 1, 33)
return hkdfExtract(sharedX, "nip44-v2".toByteArray(StandardCharsets.UTF_8))
}
private fun hkdfExtract(ikm: ByteArray, salt: ByteArray): ByteArray {
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
mac.init(javax.crypto.spec.SecretKeySpec(salt, "HmacSHA256"))
return mac.doFinal(ikm)
}
private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
val result = ByteArray(length)
var prev = ByteArray(0)
var offset = 0
var counter = 1
while (offset < length) {
mac.init(javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"))
mac.update(prev)
mac.update(info)
mac.update(counter.toByte())
prev = mac.doFinal()
val toCopy = minOf(prev.size, length - offset)
System.arraycopy(prev, 0, result, offset, toCopy)
offset += toCopy
counter++
}
return result
}
private fun hmacSha256(key: ByteArray, vararg parts: ByteArray): ByteArray {
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
mac.init(javax.crypto.spec.SecretKeySpec(key, "HmacSHA256"))
for (part in parts) mac.update(part)
return mac.doFinal()
}
// ChaCha20 block function per RFC 8439
private fun chacha20Block(key: ByteArray, counter: Int, nonce: ByteArray): ByteArray {
fun Int.rotl(n: Int) = (this shl n) or (this ushr (32 - n))
val state = IntArray(16)
state[0] = 0x61707865; state[1] = 0x3320646e; state[2] = 0x79622d32; state[3] = 0x6b206574
for (i in 0..7) state[4 + i] = (key[i*4].toInt() and 0xFF) or
((key[i*4+1].toInt() and 0xFF) shl 8) or
((key[i*4+2].toInt() and 0xFF) shl 16) or
((key[i*4+3].toInt() and 0xFF) shl 24)
state[12] = counter
for (i in 0..2) state[13 + i] = (nonce[i*4].toInt() and 0xFF) or
((nonce[i*4+1].toInt() and 0xFF) shl 8) or
((nonce[i*4+2].toInt() and 0xFF) shl 16) or
((nonce[i*4+3].toInt() and 0xFF) shl 24)
val working = state.copyOf()
repeat(10) {
fun quarterRound(a: Int, b: Int, c: Int, d: Int) {
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(16)
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(12)
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(8)
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(7)
}
quarterRound(0,4,8,12); quarterRound(1,5,9,13); quarterRound(2,6,10,14); quarterRound(3,7,11,15)
quarterRound(0,5,10,15); quarterRound(1,6,11,12); quarterRound(2,7,8,13); quarterRound(3,4,9,14)
}
val out = ByteArray(64)
for (i in 0..15) {
val v = working[i] + state[i]
out[i*4] = v.toByte()
out[i*4+1] = (v ushr 8).toByte()
out[i*4+2] = (v ushr 16).toByte()
out[i*4+3] = (v ushr 24).toByte()
}
return out
}
private fun chacha20(key: ByteArray, nonce: ByteArray, data: ByteArray): ByteArray {
val out = ByteArray(data.size)
var counter = 0
var offset = 0
while (offset < data.size) {
val block = chacha20Block(key, counter, nonce)
val len = minOf(64, data.size - offset)
for (i in 0 until len) out[offset + i] = (data[offset + i].toInt() xor block[i].toInt()).toByte()
offset += len
counter++
}
return out
}
private fun nip44CalcPaddedLen(len: Int): Int {
if (len <= 32) return 32
val nextPower = 1 shl (Math.floor(Math.log((len - 1).toDouble()) / Math.log(2.0)).toInt() + 1)
val chunk = if (nextPower <= 256) 32 else nextPower / 8
return chunk * ((len - 1) / chunk + 1)
}
private fun nip44Pad(plaintext: String): ByteArray {
val unpadded = plaintext.toByteArray(StandardCharsets.UTF_8)
val len = unpadded.size
val padded = ByteArray(2 + nip44CalcPaddedLen(len))
padded[0] = (len ushr 8).toByte()
padded[1] = len.toByte()
System.arraycopy(unpadded, 0, padded, 2, len)
return padded
}
private fun nip44Unpad(padded: ByteArray): String {
val len = ((padded[0].toInt() and 0xFF) shl 8) or (padded[1].toInt() and 0xFF)
if (len == 0 || len > padded.size - 2) return ""
return String(padded, 2, len, StandardCharsets.UTF_8)
}
private fun encryptNip44(plaintext: String, conversationKey: ByteArray): String {
return try {
val nonce = ByteArray(32).also { SecureRandom().nextBytes(it) }
val keys = hkdfExpand(conversationKey, nonce, 76)
val chachaKey = keys.sliceArray(0 until 32)
val chachaNonce = keys.sliceArray(32 until 44)
val hmacKey = keys.sliceArray(44 until 76)
val padded = nip44Pad(plaintext)
val ciphertext = chacha20(chachaKey, chachaNonce, padded)
val mac = hmacSha256(hmacKey, nonce, ciphertext)
val payload = ByteArray(1 + 32 + ciphertext.size + 32)
payload[0] = 2
System.arraycopy(nonce, 0, payload, 1, 32)
System.arraycopy(ciphertext, 0, payload, 33, ciphertext.size)
System.arraycopy(mac, 0, payload, 33 + ciphertext.size, 32)
Base64.encodeToString(payload, Base64.NO_WRAP)
} catch (_: Exception) {
""
}
}
private fun decryptNip44(payload: String, conversationKey: ByteArray): String {
return try {
if (payload.isEmpty() || payload[0] == '#') return ""
val data = Base64.decode(payload, Base64.NO_WRAP)
if (data.size < 99 || data[0] != 2.toByte()) return ""
val nonce = data.sliceArray(1 until 33)
val ciphertext = data.sliceArray(33 until data.size - 32)
val mac = data.sliceArray(data.size - 32 until data.size)
val keys = hkdfExpand(conversationKey, nonce, 76)
val chachaKey = keys.sliceArray(0 until 32)
val chachaNonce = keys.sliceArray(32 until 44)
val hmacKey = keys.sliceArray(44 until 76)
val expectedMac = hmacSha256(hmacKey, nonce, ciphertext)
if (!expectedMac.contentEquals(mac)) return ""
val padded = chacha20(chachaKey, chachaNonce, ciphertext)
nip44Unpad(padded)
} catch (_: Exception) {
""
}
}
// ---- Signing ----
private fun signWithNip01Secret(secretHex: String, eventJson: String, expectedPubkey: String): String {
return try {
val secret = hexToBytes(secretHex)
if (secret.size != 32) return ""
val event = JSONObject(eventJson)
var pubkey = event.optString("pubkey", expectedPubkey)
if (pubkey.isEmpty()) pubkey = deriveXOnlyPubkey(secretHex)
if (pubkey.isEmpty()) return ""
event.put("pubkey", pubkey)
val id = computeEventId(event)
if (id.isEmpty()) return ""
val sig = schnorrSign(secretHex, id)
if (sig.isEmpty()) return ""
event.put("id", id)
event.put("sig", sig)
event.toString()
} catch (_: Exception) {
""
}
}
private fun signWithNip55ContentResolver(packageName: String, eventJson: String, pubkey: String): String {
val uri = Uri.parse("content://$packageName.SIGN_EVENT")
var cursor: Cursor? = null
return try {
cursor = applicationContext.contentResolver.query(uri, arrayOf(eventJson, "", pubkey), "1", null, null)
if (cursor == null || !cursor.moveToFirst()) return ""
val rejIdx = cursor.getColumnIndex("rejected")
if (rejIdx >= 0) {
val v = cursor.getString(rejIdx)
if (v == "1" || v.equals("true", ignoreCase = true)) return REJECTED
}
val eventIdx = cursor.getColumnIndex("event")
if (eventIdx >= 0) cursor.getString(eventIdx) ?: "" else ""
} catch (_: Exception) {
""
} finally {
cursor?.close()
}
}
// ---- Data types ----
private data class SessionInfo(
val method: String,
val pubkey: String,
val session: JSONObject,
)
private data class Subscription(
val relay: String,
val key: String,
val filters: JSONArray,
val ignore: JSONArray?,
)
private class RelayResult {
val events = mutableListOf<JSONObject>()
var lastCursor = 0L
}
// ---- Relay listener ----
private inner class RelayListener(
private val sub: Subscription,
private val since: Long,
private val sessionInfo: SessionInfo,
private val result: RelayResult,
private val latch: CountDownLatch,
private val pool: SocketPool,
) : WebSocketListener() {
private val subId = UUID.randomUUID().toString().replace("-", "")
private var done = false
private var authed = false
private var authEventId = ""
private var nip46InFlight = false
private var pendingDone = false
override fun onOpen(webSocket: WebSocket, response: Response) {
sendReq(webSocket)
}
private fun sendReq(webSocket: WebSocket) {
val req = JSONArray()
req.put("REQ")
req.put(subId)
for (i in 0 until sub.filters.length()) {
val filter = sub.filters.optJSONObject(i) ?: continue
val shaped = JSONObject(filter.toString())
if (since > 0) shaped.put("since", since + 1)
shaped.put("limit", 1)
req.put(shaped)
}
if (req.length() <= 2) { finish(); return }
send(webSocket, req.toString())
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val message = JSONArray(text)
Log.d(TAG, "Received message from ${sub.relay}: $text")
when (message.optString(0, "")) {
"EVENT" -> {
val event = message.optJSONObject(2) ?: return
if (!matchesAnyFilter(sub.filters, event)) return
if (isIgnored(event)) return
result.events.add(event)
val createdAt = event.optLong("created_at", 0L)
if (createdAt > result.lastCursor) result.lastCursor = createdAt
}
"AUTH" -> {
// Only auth once per connection
if (!authed) {
authed = true
tryAuth(webSocket, message.optString(1, ""))
}
}
"OK" -> {
val okId = message.optString(1, "")
val accepted = message.optBoolean(2, false)
if (accepted && okId == authEventId) sendReq(webSocket)
}
"EOSE" -> {
send(webSocket, JSONArray().apply { put("CLOSE"); put(subId) }.toString())
finish()
}
}
} catch (_: Exception) {
finish()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
private fun finish() {
if (done) return
if (nip46InFlight) { pendingDone = true; return }
done = true
latch.countDown()
}
private fun isIgnored(event: JSONObject): Boolean {
val ignore = sub.ignore ?: return false
for (i in 0 until ignore.length()) {
val filter = ignore.optJSONObject(i) ?: continue
if (matchesFilter(filter, event)) return true
}
return false
}
private fun matchesAnyFilter(filters: JSONArray, event: JSONObject): Boolean {
for (i in 0 until filters.length()) {
val filter = filters.optJSONObject(i) ?: continue
if (matchesFilter(filter, event)) return true
}
return false
}
// ---- NIP-42 auth ----
private fun tryAuth(webSocket: WebSocket, challenge: String) {
if (challenge.isEmpty()) return
when (sessionInfo.method) {
"nip01" -> tryNip01Auth(webSocket, challenge)
"nip55" -> tryNip55Auth(webSocket, challenge)
"nip46" -> tryNip46Auth(webSocket, challenge)
// Pomade background auth is not supported: properly delegating to the Pomade signer
// from a background worker is complex, usage is rare, and relays that require auth
// may still be readable without it.
}
}
private fun buildAuthEvent(challenge: String): JSONObject {
return JSONObject().apply {
put("kind", KIND_RELAY_AUTH)
put("pubkey", sessionInfo.pubkey)
put("created_at", System.currentTimeMillis() / 1000L)
put("content", "")
put("id", "")
put("sig", "")
put("tags", JSONArray().apply {
put(JSONArray().apply { put("relay"); put(sub.relay) })
put(JSONArray().apply { put("challenge"); put(challenge) })
})
}
}
private fun sendAuthMessage(webSocket: WebSocket, signedEventJson: String): Boolean {
if (signedEventJson.isEmpty() || signedEventJson == REJECTED) return false
return try {
val event = JSONObject(signedEventJson)
authEventId = event.optString("id", "")
send(webSocket, JSONArray().apply { put("AUTH"); put(event) }.toString())
} catch (_: Exception) {
false
}
}
private fun send(webSocket: WebSocket, message: String): Boolean {
Log.d(TAG, "Sending message to ${webSocket.request().url}: $message")
return webSocket.send(message)
}
private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean {
val secret = sessionInfo.session.optString("secret", "")
if (secret.isEmpty()) return false
val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
return sendAuthMessage(webSocket, signed)
}
private fun tryNip55Auth(webSocket: WebSocket, challenge: String): Boolean {
val signerPackage = sessionInfo.session.optString("signer", "")
if (signerPackage.isEmpty()) return false
val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
return sendAuthMessage(webSocket, signed)
}
private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean {
val handler = sessionInfo.session.optJSONObject("handler") ?: return false
val clientSecret = sessionInfo.session.optString("secret", "")
val signerPubkey = handler.optString("pubkey", "")
val relays = handler.optJSONArray("relays")
if (clientSecret.isEmpty() || signerPubkey.isEmpty() || relays == null || relays.length() == 0) return false
val clientPubkey = deriveXOnlyPubkey(clientSecret)
if (clientPubkey.isEmpty()) return false
val authEventJson = buildAuthEvent(challenge).toString()
nip46InFlight = true
var success = false
try {
for (i in 0 until relays.length()) {
val signerRelay = relays.optString(i, "").trim()
if (!signerRelay.startsWith("wss://") && !signerRelay.startsWith("ws://")) continue
if (tryNip46ViaRelay(webSocket, signerRelay, clientSecret, clientPubkey, signerPubkey, authEventJson)) { success = true; break }
}
} finally {
nip46InFlight = false
if (pendingDone) finish()
}
return success
}
private fun tryNip46ViaRelay(
relaySocket: WebSocket,
signerRelay: String,
clientSecret: String,
clientPubkey: String,
signerPubkey: String,
authEventJson: String,
): Boolean {
val localLatch = CountDownLatch(1)
val signedEvent = StringBuilder()
val requestId = UUID.randomUUID().toString().replace("-", "")
val signerSocket = pool.open(signerRelay, object : WebSocketListener() {
private var done = false
override fun onOpen(webSocket: WebSocket, response: Response) {
try {
val rpcEnvelope = JSONObject().apply {
put("kind", KIND_NIP46_RPC)
put("pubkey", clientPubkey)
put("created_at", System.currentTimeMillis() / 1000L)
put("content", encryptNip44(
JSONObject().apply {
put("id", requestId)
put("method", "sign_event")
put("params", JSONArray().apply { put(authEventJson) })
}.toString(),
nip44ConversationKey(clientSecret, signerPubkey),
))
put("id", "")
put("sig", "")
put("tags", JSONArray().apply { put(JSONArray().apply { put("p"); put(signerPubkey) }) })
}
val signedEnvelope = signWithNip01Secret(clientSecret, rpcEnvelope.toString(), clientPubkey)
if (signedEnvelope.isEmpty()) { finish(); return }
val sentAt = System.currentTimeMillis() / 1000L
send(webSocket, JSONArray().apply { put("EVENT"); put(JSONObject(signedEnvelope)) }.toString())
send(webSocket, JSONArray().apply {
put("REQ")
put(requestId)
put(JSONObject().apply {
put("#p", JSONArray().apply { put(clientPubkey) })
put("kinds", JSONArray().apply { put(KIND_NIP46_RPC) })
put("since", sentAt)
put("limit", 10)
})
}.toString())
} catch (_: Exception) {
finish()
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val message = JSONArray(text)
val msgType = message.optString(0, "")
Log.d(TAG, "NIP-46 signer message: $msgType from ${webSocket.request().url}")
if (msgType != "EVENT") return
val event = message.optJSONObject(2) ?: return
val tags = event.optJSONArray("tags")
var hasP = false
if (tags != null) {
for (i in 0 until tags.length()) {
val tag = tags.optJSONArray(i) ?: continue
if (tag.optString(0, "") == "p" && tag.optString(1, "") == clientPubkey) { hasP = true; break }
}
}
if (!hasP) { Log.d(TAG, "NIP-46 event missing p tag for client"); return }
val decryptedContent = decryptNip44(event.optString("content", ""), nip44ConversationKey(clientSecret, signerPubkey))
Log.d(TAG, "NIP-46 decrypted response: $decryptedContent")
if (decryptedContent.isEmpty()) return
val payload = JSONObject(decryptedContent)
if (requestId == payload.optString("id", "")) {
val result = payload.optString("result", "")
if (result.isNotEmpty()) {
signedEvent.setLength(0)
signedEvent.append(result)
finish()
}
}
} catch (e: Exception) {
Log.e(TAG, "NIP-46 signer message error", e)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
private fun finish() {
if (!done) { done = true; localLatch.countDown() }
}
})
try {
localLatch.await(5, TimeUnit.SECONDS)
} catch (_: InterruptedException) {
return false
}
if (signedEvent.isEmpty()) return false
val authEvent = JSONObject(signedEvent.toString())
authEventId = authEvent.optString("id", "")
val authMessage = JSONArray().apply { put("AUTH"); put(authEvent) }.toString()
Log.d(TAG, "NIP-46 sending AUTH to relay ${relaySocket.request().url}: $authMessage")
return try {
relaySocket.send(authMessage)
} catch (e: Exception) {
Log.e(TAG, "NIP-46 failed to send AUTH", e)
false
}
}
}
}
-2
View File
@@ -1,7 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '2.2.20'
repositories {
google()
@@ -10,7 +9,6 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'com.google.gms:google-services:4.4.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
-3
View File
@@ -2,9 +2,6 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/android/capacitor')
include ':aparajita-capacitor-secure-storage'
project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android')
include ':capacitor-community-safe-area'
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area/android')
+4 -4
View File
@@ -358,14 +358,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 34;
CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.1;
MARKETING_VERSION = 1.6.5;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -385,14 +385,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 34;
CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.1;
MARKETING_VERSION = 1.6.5;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
-1
View File
@@ -11,7 +11,6 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
+12 -13
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "1.7.1",
"version": "1.6.5",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -42,7 +42,6 @@
},
"type": "module",
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@capacitor-community/safe-area": "^8.0.1",
"@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0",
@@ -58,7 +57,7 @@
"@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.1",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2",
@@ -66,16 +65,16 @@
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.8.10",
"@welshman/content": "^0.8.10",
"@welshman/editor": "^0.8.10",
"@welshman/feeds": "^0.8.10",
"@welshman/lib": "^0.8.10",
"@welshman/net": "^0.8.10",
"@welshman/router": "^0.8.10",
"@welshman/signer": "^0.8.10",
"@welshman/store": "^0.8.10",
"@welshman/util": "^0.8.10",
"@welshman/app": "^0.8.9",
"@welshman/content": "^0.8.9",
"@welshman/editor": "^0.8.9",
"@welshman/feeds": "^0.8.9",
"@welshman/lib": "^0.8.9",
"@welshman/net": "^0.8.9",
"@welshman/router": "^0.8.9",
"@welshman/signer": "^0.8.9",
"@welshman/store": "^0.8.9",
"@welshman/util": "^0.8.9",
"compressorjs-next": "^1.1.2",
"daisyui": "^4.12.24",
"date-picker-svelte": "^2.17.0",
+114 -196
View File
@@ -11,9 +11,6 @@ importers:
.:
dependencies:
'@aparajita/capacitor-secure-storage':
specifier: ^8.0.0
version: 8.0.0
'@capacitor-community/safe-area':
specifier: ^8.0.1
version: 8.0.1(@capacitor/core@8.0.1)
@@ -60,8 +57,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.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.10(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(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.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.2.1
version: 0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(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.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(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)(terser@5.46.0))
@@ -84,35 +81,35 @@ importers:
specifier: ^0.6.8
version: 0.6.8(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.48.0)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.10)(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)(terser@5.46.0))(workbox-build@7.3.0)(workbox-window@7.3.0))
'@welshman/app':
specifier: ^0.8.10
version: 0.8.10(f8bcfd183423c537ab4935d047335c0f)
specifier: ^0.8.9
version: 0.8.9(56a9569377ccbc308c0adef0d87b4892)
'@welshman/content':
specifier: ^0.8.10
version: 0.8.10(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.9
version: 0.8.9(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/editor':
specifier: ^0.8.10
version: 0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.9
version: 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/feeds':
specifier: ^0.8.10
version: 0.8.10(d287ec628e3b45481639b01eedf791d2)
specifier: ^0.8.9
version: 0.8.9(e7b1650516a86ec271bd2c0f047c2e03)
'@welshman/lib':
specifier: ^0.8.10
version: 0.8.10
specifier: ^0.8.9
version: 0.8.9
'@welshman/net':
specifier: ^0.8.10
version: 0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
specifier: ^0.8.9
version: 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router':
specifier: ^0.8.10
version: 0.8.10(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))
specifier: ^0.8.9
version: 0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer':
specifier: ^0.8.10
version: 0.8.10(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(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))
specifier: ^0.8.9
version: 0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/store':
specifier: ^0.8.10
version: 0.8.10(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
specifier: ^0.8.9
version: 0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
'@welshman/util':
specifier: ^0.8.10
version: 0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.9
version: 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
compressorjs-next:
specifier: ^1.1.2
version: 1.1.2
@@ -235,10 +232,6 @@ packages:
'@antfu/utils@0.7.10':
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
'@aparajita/capacitor-secure-storage@8.0.0':
resolution: {integrity: sha512-oYnwSjdIh23aRNgz8982+TmFvQH/2yZkEdw1iIg+H2ziFJoOVELPTc7u6Ez2HwOuDIW5AGqBX75GvrzQ+D70Qg==}
engines: {node: '>=20.0.0'}
'@apideck/better-ajv-errors@0.3.6':
resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==}
engines: {node: '>=10'}
@@ -763,11 +756,6 @@ packages:
peerDependencies:
'@capacitor/core': ^8.0.0
'@capacitor/android@8.2.0':
resolution: {integrity: sha512-XLm5OsWLPfXQxDxzFS7SOdMEgGvW+2c7TGLXkTR2cSKdkWK5Abns4imlT5qghKYhjM9r74IrDkBWg/9ALUGNKQ==}
peerDependencies:
'@capacitor/core': ^8.2.0
'@capacitor/app@8.0.0':
resolution: {integrity: sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA==}
peerDependencies:
@@ -791,9 +779,6 @@ packages:
'@capacitor/core@8.0.1':
resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==}
'@capacitor/core@8.2.0':
resolution: {integrity: sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==}
'@capacitor/filesystem@8.1.0':
resolution: {integrity: sha512-AfawIxQ8xBmKsEn/vEpgurGQB9+hFXRtwEiCXR+SSS0MkTw4bJrvLGnloZ/PblegYefvnay1q079Yz3PQ6y1dA==}
peerDependencies:
@@ -804,11 +789,6 @@ packages:
peerDependencies:
'@capacitor/core': ^8.0.0
'@capacitor/ios@8.2.0':
resolution: {integrity: sha512-X2/VtM4qP/R1SM0VQ5W/VotEc6PS/KTooD33EijsfAHWBdee+xmBapW8SeNLnu16wJ+tsfWlvtipaJEyfKbRKQ==}
peerDependencies:
'@capacitor/core': ^8.2.0
'@capacitor/keyboard@8.0.0':
resolution: {integrity: sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g==}
peerDependencies:
@@ -1124,105 +1104,89 @@ packages:
resolution: {integrity: sha512-HWpu3wRqss0vqze56Y/peCrMOsILjoorwU0ZiqF4dYQIl03dD4k71tHstC2/y+7KqNtgb7+ItSdXJydfwspDyA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.0-rc.2':
resolution: {integrity: sha512-tyXAQ0WCfXZf2dwm7F+IN/t/s324EcdpbW3dh8rwh8NHIkijeHGyiAHs45Bs8SnsTM/RjR+uPigxFMF/QYAiTw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.0-rc.2':
resolution: {integrity: sha512-lfJrlawp2PjxBu3Nh/2EOsgigNgr2o8MOG3XS2ibkKpJ3K/1YcUu9sTQV0S/n8+ak2R9MmJ3uTJqRVjdYkwWxg==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.0-rc.2':
resolution: {integrity: sha512-2WUcL/k7uk6i5ZSXCQmOGgGxwsfKEtJA28vNfFb6gT+Zv7vdAMbnjjGMFRtTT7RUcFHgN2olvhfnEjtIMY49MQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.0-rc.2':
resolution: {integrity: sha512-wx8/WmVA+kwLfwEN6UzjFlRz6erSibq4nGItfH3Nv+OITCjx8pH3Sl67T0tbwjU3M24GOcDFBIJ6rB+2oXbMzQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.0-rc.2':
resolution: {integrity: sha512-Rk8oOssrmTzhiuhDMPCw5Gadd4/mj5QPqrkxbg0R1VEaQeNo51d4YNbIokEDp2PqSTo+unUs6SHN8prkFKHP5A==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.0-rc.2':
resolution: {integrity: sha512-K2TaPlrPox8uf3K01R8S+AfhPqVVWlEK6+RxkJNGasN0k1iFhu9hMhWl7+sEiWj2V30TKcppRqQWUv7H3qym9w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.0-rc.2':
resolution: {integrity: sha512-88HtWiP7sBX6rb1Hw7cf3H+1ufkB+YocfeMFtmAg6oOc8hvNcby8MVPyE7GL+YD7LKPBadcScaeVnGzYWYRaMQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.35.0-rc.0':
resolution: {integrity: sha512-6pdCj+H0+sNsX7vpbxF2FhDF+fe7Hc/mfjlG5caFVUrACIMMOMc962xTE0Y1+XE3EWM8hUmGp3y6hISjJDnwYA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.35.0-rc.0':
resolution: {integrity: sha512-oy0+atKDov9vn9mMVlyS0V2BTKTFzR3cFhZ9ilF98vnGSvrWdevUlfjNdTE5pN/xZq6z0GnkIAUL5KJhwh5yzQ==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.35.0-rc.0':
resolution: {integrity: sha512-SvX6E6h/i/zhhGheJiSHbFb3loglNDi+H6wEpnPdp5SGlyiJabLVco93kHBeo4lkfQAwYACRW+yFhRfNhhZO3Q==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.35.0-rc.0':
resolution: {integrity: sha512-EY+6k/u87hfZgBXJaNNRwEN1ACPHUudcW2ObVmNA1eADnFircVvdPjvKUCc5bVwzjmG1imzNJWoapIB9wZheFQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.35.0-rc.0':
resolution: {integrity: sha512-owTWB3KiGs03QpcHFGNBToB5DIEuCsHugzVS7h8Za6f2W0Nw8qedJF5oSIr71nBj4jF4NmTMd5w7EFOOKn8pFA==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.35.0-rc.0':
resolution: {integrity: sha512-XQoXfEHwz0TTtf6DMT7rLXNc7qb7okjSe+8vzusdTuqVXBi+km+Jwvc9DKL9azMWKR4TR/ArvQ7Y5dFnckb9VA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.0-rc.0':
resolution: {integrity: sha512-hfCXVq35g/zA+Lpa3x1gUXHLi0rLWUPbVGpbPox2zyx2byfKXf5Lcq6xoMJUrQkmT+s8BaOP8TbmhG8ZQgUFyw==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.0-rc.0':
resolution: {integrity: sha512-TsV3KFF9i2wKHHsXnRz9N5H66swuDqpk+KPiTugbtdTo+um33BoFZC7F+Ty+DBoOWf4TT+j7IiJfpen5bC5FRw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.35.0-rc.0':
resolution: {integrity: sha512-5eRvTRqUbNDEd999tRRwXaEO5CERA1WDiVrNDgh+g0IlhCJ79jQkfTE+/dKEO8VbhUwVT6qWFjse+/3KjXhUKg==}
@@ -1424,9 +1388,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.1':
resolution: {integrity: sha512-zXpPQPkhVe7OchmRDe2MbHdUxiCSeUuMwrHOyeOBs/xD1EfY093Mwj6Cu/OLfz0wxivBDSp1GMMmxqKbLWam3Q==}
version: 0.2.1
engines: {node: '>=12.0.0'}
peerDependencies:
'@frostr/bifrost': ^1.0.7
@@ -1536,79 +1500,66 @@ packages:
resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.56.0':
resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.56.0':
resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.56.0':
resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.56.0':
resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.56.0':
resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.56.0':
resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.56.0':
resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.56.0':
resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.56.0':
resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.56.0':
resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.56.0':
resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.56.0':
resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.56.0':
resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==}
@@ -1731,35 +1682,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.10.0':
resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.0':
resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.10.0':
resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.10.0':
resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.10.0':
resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==}
@@ -2036,83 +1982,83 @@ packages:
'@vite-pwa/assets-generator':
optional: true
'@welshman/app@0.8.10':
resolution: {integrity: sha512-XwcwQ1bfRebbnJK0FHXWo4nPVSrqbvQ/XeiyOpxrY2uz6zTNKRe8ep8/v8m0rPeoJNQN8MNceIzqa+QRUu40Lg==}
'@welshman/app@0.8.9':
resolution: {integrity: sha512-2ff0Y9JzSVqJz9qY8vPDY7CC9xBZ5KQPLlVRX2OGnwopmLm9P68i6u8eJG53caxCUv+d7RCDXNlYOkFH6hr7nw==}
peerDependencies:
'@pomade/core': ^0.2.1
'@welshman/feeds': 0.8.10
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10
'@welshman/router': 0.8.10
'@welshman/signer': 0.8.10
'@welshman/store': 0.8.10
'@welshman/util': 0.8.10
'@welshman/feeds': 0.8.9
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9
'@welshman/router': 0.8.9
'@welshman/signer': 0.8.9
'@welshman/store': 0.8.9
'@welshman/util': 0.8.9
svelte: ^4.0.0 || ^5.0.0
'@welshman/content@0.8.10':
resolution: {integrity: sha512-+5a61ir8Jj0xy0JaqBhuWcq1WhgsT1VQorUVLVjdQZPG5wdFsnVHkSQLib/7MlzUuLeJMQVHFwVvd35iu0kAUg==}
'@welshman/content@0.8.9':
resolution: {integrity: sha512-K9r1hAooqM857Ze4i0kF/LSqOZjhuYDsbY07kA1pjbkfrgf8cLuaVP+qicDxGfHD4kKDWuCb/PkUf2kC8nOjuQ==}
peerDependencies:
nostr-tools: ^2.19.4
'@welshman/editor@0.8.10':
resolution: {integrity: sha512-TDUQHIHAOGoep7I7PdNNtPJ36rZLOppnCHpuYDC7rHZQCvprHW3C8JVrOZQFtbvZIcRf3psrnxnoeBuR/I1OtQ==}
'@welshman/editor@0.8.9':
resolution: {integrity: sha512-iDBp/qaZBGaaKfSk+7hrJkgzssovZLAkoT5ULjFoBpT9NfpJu/5WYfoUYwKxa6fS9Z84veS39y/PW1LFBNPnpg==}
peerDependencies:
'@welshman/lib': 0.8.10
'@welshman/util': 0.8.10
'@welshman/lib': 0.8.9
'@welshman/util': 0.8.9
nostr-editor: ^1.1.1
nostr-tools: ^2.19.4
'@welshman/feeds@0.8.10':
resolution: {integrity: sha512-h03YWlbYaa1g9fwDmUXj0BHcp4+Pz40s5soGWlWzZtI7ItnQ61+Y6V/adfOhpXWFDBK1jDA457BkI1eR/dc50A==}
'@welshman/feeds@0.8.9':
resolution: {integrity: sha512-8JI6rrETqDqa9VdU0eEP1OBvTjnampfgs0ZhdOELy9itFcuqDz8wPScnw3sygApL53tkFv4n2xnjZE7E3N3U/w==}
peerDependencies:
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10
'@welshman/router': 0.8.10
'@welshman/signer': 0.8.10
'@welshman/util': 0.8.10
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9
'@welshman/router': 0.8.9
'@welshman/signer': 0.8.9
'@welshman/util': 0.8.9
'@welshman/lib@0.8.10':
resolution: {integrity: sha512-QAdyeHIpC8/kl496orZG5Y7H8HX9s4KaxJbkhNhwBzp7HlBmWWvlxJpBVRaLid9Q7ZkTJjWuAu+2d1jAa/rsFg==}
'@welshman/lib@0.8.9':
resolution: {integrity: sha512-Gk9MXaJNuLL9EguP2RnoaGaQy6x0BrneZfj9gL5t6ZNIF+1g+maJssKDbCRjdDPeuNQbRhh7AlSGSQUJuhkq6Q==}
engines: {node: '>=12.0.0'}
'@welshman/net@0.8.10':
resolution: {integrity: sha512-cQI0EzsvGYe5M7UANHYqRyespTAX5zC4mbFK9uGXXDqjjyvBygVnEbLNDqnO0AIpcI1tEAB2VXyGcfCQFrDdTw==}
'@welshman/net@0.8.9':
resolution: {integrity: sha512-0brgfS7pHlE23CmAVLZ/RCGVvyjq+MX4NAhFyqxXuCCcqN7Lf9t60aQ6Z0aKl2dA79yVDtS6d1S53RR1rNS+Ew==}
peerDependencies:
'@welshman/lib': 0.8.10
'@welshman/util': 0.8.10
'@welshman/lib': 0.8.9
'@welshman/util': 0.8.9
'@welshman/router@0.8.10':
resolution: {integrity: sha512-3F+C5n7dloxrznelj3rxPJBo9rS2jJO8FS+c12PwZkfGaCX9To35aqRkia/2DiDJO9Dcm4D/D59WyT7/EPRAgQ==}
'@welshman/router@0.8.9':
resolution: {integrity: sha512-Kjf7CyO8wvnsVS3TX0eRUVd327F4vsDUdJFpo1MYjKRmgwj7ebOiofY8Fx011BX/GcpVVq7pUFs5792cSjlsrQ==}
peerDependencies:
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10
'@welshman/util': 0.8.10
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9
'@welshman/util': 0.8.9
'@welshman/signer@0.8.10':
resolution: {integrity: sha512-JTzOzSbzmgux+WpMMMqeNw3sdzs2PvFLROZ7m8ArWASFrXw6qDE74v7k3Ir5WAMUTxbee+GXRRVHoeE3QFhDIw==}
version: 0.8.10
'@welshman/signer@0.8.9':
resolution: {integrity: sha512-PVnZn5Rz+10hH35f350JVo1ug3qFeunraOpCcPkmo8XVA0bEEY5503OMGfu1osfEI6KMseoFPU/AdZrh+w+ZQQ==}
version: 0.8.9
peerDependencies:
'@noble/curves': ^1.9.7
'@noble/hashes': ^2.0.1
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10
'@welshman/util': 0.8.10
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9
'@welshman/util': 0.8.9
nostr-signer-capacitor-plugin: '*'
nostr-tools: ^2.19.4
'@welshman/store@0.8.10':
resolution: {integrity: sha512-hkVZcttU8dIIAZxX19liB0SWNULF//nhjr7xBAhwRcm2rPsFLsW65ofwVx61PsYTrl7BKQ22Z5JUJOdxIVPz0Q==}
'@welshman/store@0.8.9':
resolution: {integrity: sha512-wchFOvQB/E5/j5oyqw0QmIx1XzWtm0b3b2mtNUKF7bdtX7YskiSeLcXalJTALD+WkW02cGzBw2SvoJjtDiyWnw==}
peerDependencies:
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10
'@welshman/util': 0.8.10
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9
'@welshman/util': 0.8.9
svelte: ^4.0.0 || ^5.0.0
'@welshman/util@0.8.10':
resolution: {integrity: sha512-Ur0SKpOIZYGqJiBNAlSyRSkQndQfxx5RBMQQG2ZZw7alZ2ekFCor/fuUn/dsb3X2Bpk5CZYr2Mn+ObVzkYKgLg==}
'@welshman/util@0.8.9':
resolution: {integrity: sha512-oOijx0PCsTVhPOPr+5HS4mZFntrtHAW8cdBvJqu/Asf+m6UrvVCeuoF3NDtKhWbkuD6uZnfeJy6WDKVhTptZEA==}
peerDependencies:
'@noble/curves': ^1.9.7
'@welshman/lib': 0.8.10
'@welshman/lib': 0.8.9
nostr-tools: ^2.19.4
'@xml-tools/parser@1.0.11':
@@ -5162,14 +5108,6 @@ snapshots:
'@antfu/utils@0.7.10': {}
'@aparajita/capacitor-secure-storage@8.0.0':
dependencies:
'@capacitor/android': 8.2.0(@capacitor/core@8.2.0)
'@capacitor/app': 8.0.0(@capacitor/core@8.2.0)
'@capacitor/core': 8.2.0
'@capacitor/ios': 8.2.0(@capacitor/core@8.2.0)
'@capacitor/keyboard': 8.0.0(@capacitor/core@8.2.0)
'@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)':
dependencies:
ajv: 8.18.0
@@ -5851,18 +5789,10 @@ snapshots:
dependencies:
'@capacitor/core': 8.0.1
'@capacitor/android@8.2.0(@capacitor/core@8.2.0)':
dependencies:
'@capacitor/core': 8.2.0
'@capacitor/app@8.0.0(@capacitor/core@8.0.1)':
dependencies:
'@capacitor/core': 8.0.1
'@capacitor/app@8.0.0(@capacitor/core@8.2.0)':
dependencies:
'@capacitor/core': 8.2.0
'@capacitor/assets@3.0.5(@types/node@25.0.10)(typescript@5.9.3)':
dependencies:
'@capacitor/cli': 5.7.8
@@ -5933,10 +5863,6 @@ snapshots:
dependencies:
tslib: 2.8.1
'@capacitor/core@8.2.0':
dependencies:
tslib: 2.8.1
'@capacitor/filesystem@8.1.0(@capacitor/core@8.0.1)':
dependencies:
'@capacitor/core': 8.0.1
@@ -5946,18 +5872,10 @@ snapshots:
dependencies:
'@capacitor/core': 8.0.1
'@capacitor/ios@8.2.0(@capacitor/core@8.2.0)':
dependencies:
'@capacitor/core': 8.2.0
'@capacitor/keyboard@8.0.0(@capacitor/core@8.0.1)':
dependencies:
'@capacitor/core': 8.0.1
'@capacitor/keyboard@8.0.0(@capacitor/core@8.2.0)':
dependencies:
'@capacitor/core': 8.2.0
'@capacitor/preferences@8.0.0(@capacitor/core@8.0.1)':
dependencies:
'@capacitor/core': 8.0.1
@@ -6570,15 +6488,15 @@ 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.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.10(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(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.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))':
'@pomade/core@0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(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.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(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
'@peculiar/x509': 1.14.3
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/signer': 0.8.10(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(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.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/signer': 0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(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.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
cbor-x: 1.6.0
hash-wasm: 4.12.0
nostr-tools: 2.20.0(typescript@5.9.3)
@@ -7167,26 +7085,26 @@ snapshots:
optionalDependencies:
'@vite-pwa/assets-generator': 0.2.6
'@welshman/app@0.8.10(f8bcfd183423c537ab4935d047335c0f)':
'@welshman/app@0.8.9(56a9569377ccbc308c0adef0d87b4892)':
dependencies:
'@pomade/core': 0.2.2(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.10(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(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.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/feeds': 0.8.10(d287ec628e3b45481639b01eedf791d2)
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.10(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.10(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/store': 0.8.10(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
'@welshman/util': 0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3))
'@pomade/core': 0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(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.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/feeds': 0.8.9(e7b1650516a86ec271bd2c0f047c2e03)
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/store': 0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
fuse.js: 7.1.0
svelte: 5.48.0
throttle-debounce: 5.0.2
'@welshman/content@0.8.10(nostr-tools@2.20.0(typescript@5.9.3))':
'@welshman/content@0.8.9(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies:
'@braintree/sanitize-url': 7.1.1
nostr-tools: 2.20.0(typescript@5.9.3)
'@welshman/editor@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))':
'@welshman/editor@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
'@tiptap/extension-code': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
@@ -7201,64 +7119,64 @@ snapshots:
'@tiptap/extension-text': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
'@tiptap/pm': 2.27.2
'@tiptap/suggestion': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)
'@welshman/lib': 0.8.10
'@welshman/util': 0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
nostr-editor: 1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))
nostr-tools: 2.20.0(typescript@5.9.3)
tippy.js: 6.3.7
'@welshman/feeds@0.8.10(d287ec628e3b45481639b01eedf791d2)':
'@welshman/feeds@0.8.9(e7b1650516a86ec271bd2c0f047c2e03)':
dependencies:
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.10(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.10(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(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.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(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.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
trava: 1.2.1
'@welshman/lib@0.8.10':
'@welshman/lib@0.8.9':
dependencies:
'@scure/base': 1.2.6
'@types/events': 3.0.3
events: 3.3.0
'@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)':
'@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)':
dependencies:
'@welshman/lib': 0.8.10
'@welshman/util': 0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
events: 3.3.0
isomorphic-ws: 5.0.0(ws@8.18.3)
transitivePeerDependencies:
- ws
'@welshman/router@0.8.10(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))':
'@welshman/router@0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))':
dependencies:
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/signer@0.8.10(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))':
'@welshman/signer@0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies:
'@noble/curves': 1.9.7
'@noble/hashes': 2.0.1
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
nostr-signer-capacitor-plugin: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1)
nostr-tools: 2.20.0(typescript@5.9.3)
'@welshman/store@0.8.10(@welshman/lib@0.8.10)(@welshman/net@0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)':
'@welshman/store@0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)':
dependencies:
'@welshman/lib': 0.8.10
'@welshman/net': 0.8.10(@welshman/lib@0.8.10)(@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
svelte: 5.48.0
'@welshman/util@0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3))':
'@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies:
'@noble/curves': 1.9.7
'@types/ws': 8.18.1
'@welshman/lib': 0.8.10
'@welshman/lib': 0.8.9
js-base64: 3.7.8
nostr-tools: 2.20.0(typescript@5.9.3)
nostr-wasm: 0.1.0
+42
View File
@@ -394,6 +394,35 @@ progress[value]::-webkit-progress-value {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cw-video-call-content {
@apply w-full md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
}
/* Voice: desktop split — plain CSS so / in calc is not parsed as Tailwind slash syntax */
.cw-split-video {
width: 100%;
}
.cw-split-chat {
width: 100%;
}
@media (min-width: 768px) {
.cw-split-video {
left: 18.5rem;
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
.cw-split-chat {
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
}
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
}
@@ -430,6 +459,19 @@ body.keyboard-open .hide-on-keyboard {
@apply min-w-0;
}
.chat__compose-zone.cw-video-call-content {
@apply md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
}
@media (min-width: 768px) {
.chat__compose-zone.cw-split-chat {
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
}
.chat__scroll-down {
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
}
+1 -1
View File
@@ -20,7 +20,7 @@
{@render children?.()}
</PrimaryNav>
{:else if !$modal}
<Dialog noEscape children={{component: Landing, props: {}}} />
<Dialog children={{component: Landing, props: {}}} />
{/if}
</div>
<Toast />
+60 -51
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {
ago,
int,
@@ -35,15 +34,14 @@
messagingRelayListsByPubkey,
} from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
@@ -53,7 +51,7 @@
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {userSettingsValues, deriveChat} from "@app/core/state"
import {userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {makeDelete, prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
@@ -67,15 +65,13 @@
const chat = deriveChat(pubkeys)
const others = remove($pubkey!, pubkeys)
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
const missingRelayLists = $derived(pubkeys.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
const showMembers = () =>
others.length === 1
? pushModal(ProfileDetail, {pubkey: others[0]})
: pushModal(ChatMembers, {pubkeys: others})
const back = () => goto("/chat")
const replyTo = (event: TrustedEvent) => {
parent = event
compose?.focus()
@@ -107,7 +103,6 @@
await sendWrapped({
event: makeDelete({event: eventToEdit, protect: false}),
recipients: pubkeys,
pow: 16,
})
}
@@ -157,7 +152,6 @@
event,
recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i),
pow: 16,
}),
),
)
@@ -255,59 +249,75 @@
</script>
<PageBar>
<div class="flex">
<Button onclick={back} class="place-self-start pr-3 md:hidden flex items-center">
<Icon icon={ArrowLeft} size={7} />
</Button>
<div class="flex items-center justify-between gap-4">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
{#if others.length === 0}
<div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} />
</div>
{:else if others.length === 1}
<div class="row-2">
<ProfileCircle pubkey={others[0]} size={5} />
<div class="flex items-center justify-between gap-4">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
{#if others.length === 0}
<div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} />
</div>
{:else if others.length === 1}
<div class="row-2">
<ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} />
</div>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
</div>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{/if}
</Button>
</div>
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{/if}
</Button>
</div>
{#if remove($pubkey, missingRelayLists).length > 0}
{@const count = remove($pubkey, missingRelayLists).length}
{@const label = count > 1 ? "lists are" : "list is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} messaging {label} not configured.">
<Icon icon={Danger} />
{count}
</div>
{/if}
</div>
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingRelayLists.length > 0}
{#if missingRelayLists.includes($pubkey!)}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon={Danger} />
Direct messages are not enabled
Your messaging relays are not configured.
</p>
<p>
Ask
{#each missingRelayLists as pubkey (pubkey)}
<ProfileLink {pubkey} />
{/each}
to enable direct messaging by opening this conversation in their app.
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to receive messages.
</p>
</div>
</div>
{:else if missingRelayLists.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon={Danger} />
{missingRelayLists.length} messaging
{missingRelayLists.length > 1 ? "lists are" : "list is"} not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their messaging relays.
</p>
</div>
</div>
@@ -352,7 +362,6 @@
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
disabled={Boolean(missingRelayLists.length)} />
content={eventToEdit?.content} />
{/key}
</div>
+6 -14
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte"
import {writable} from "svelte/store"
import cx from "classnames"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@@ -13,24 +12,17 @@
type Props = {
content?: string
disabled?: boolean
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
}
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
const {content, onEscape, onEditPrevious, onSubmit}: Props = $props()
const autofocus = !isMobile && !disabled
const autofocus = !isMobile
const uploading = writable(false)
const editorClass = $derived(
cx("chat-editor flex-grow overflow-hidden", {
"pointer-events-none opacity-50": disabled,
}),
)
export const focus = () => editor.then(ed => ed.chain().focus().run())
export const canEnterEditPrevious = () =>
@@ -49,7 +41,7 @@
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading || disabled) return
if ($uploading) return
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
@@ -86,7 +78,7 @@
<Button
data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$uploading || disabled}
disabled={$uploading}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
@@ -94,13 +86,13 @@
<Icon icon={GallerySend} />
{/if}
</Button>
<div class={editorClass} aria-disabled={disabled}>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading || disabled}
disabled={$uploading}
onclick={submit}>
<Icon icon={Plane} />
</Button>
-72
View File
@@ -1,72 +0,0 @@
<script lang="ts">
import {getRelaysFromList} from "@welshman/util"
import {waitForThunkError, setMessagingRelays, userRelayList, setRelays} from "@welshman/app"
import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/core/state"
import {pushToast} from "@app/util/toast"
type Props = {
next: () => void
}
const {next}: Props = $props()
let loading = $state(false)
const back = () => history.back()
const enable = async () => {
loading = true
try {
if (getRelaysFromList($userRelayList).length === 0) {
const error = await waitForThunkError(await setRelays(DEFAULT_RELAYS.map(r => ["r", r])))
if (error) {
pushToast({theme: "error", message: error})
return
}
}
const error = await waitForThunkError(await setMessagingRelays(DEFAULT_MESSAGING_RELAYS))
if (error) {
pushToast({theme: "error", message: error})
return
}
await next()
} finally {
loading = false
}
}
</script>
<Modal tag="form" onsubmit={preventDefault(enable)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Enable direct messaging?</ModalTitle>
</ModalHeader>
<p>Direct messaging isn't currently enabled. Would you like to turn it on?</p>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Enable direct messaging</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</Modal>
+4 -5
View File
@@ -5,11 +5,11 @@
import type {TrustedEvent} from "@welshman/util"
import {pubkey, loadMessagingRelayList} from "@welshman/app"
import {fade} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath, goToChat} from "@app/util/routes"
import {makeChatPath} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
interface Props {
@@ -24,7 +24,6 @@
const others = uniq(remove($pubkey!, props.pubkeys))
const active = $derived($page.params.chat === props.id)
const path = makeChatPath(props.pubkeys)
const openChat = () => goToChat(props.pubkeys)
onMount(() => {
for (const pk of others) {
@@ -33,7 +32,7 @@
})
</script>
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
<Link class="flex flex-col justify-start gap-1" href={makeChatPath(props.pubkeys)}>
<div
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
class:bg-base-100={active}>
@@ -72,4 +71,4 @@
</p>
</div>
</div>
</Button>
</Link>
+2 -6
View File
@@ -40,14 +40,10 @@
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
const deleteReaction = (event: TrustedEvent) =>
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys, pow: 16})
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys})
const createReaction = (template: EventContent) =>
sendWrapped({
event: makeReaction({event, protect: false, ...template}),
recipients: pubkeys,
pow: 16,
})
sendWrapped({event: makeReaction({event, protect: false, ...template}), recipients: pubkeys})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@@ -18,7 +18,6 @@
sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys,
pow: 16,
})
</script>
@@ -31,7 +31,6 @@
sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys,
pow: 16,
})
}).bind(undefined, event, pubkeys)
+3 -2
View File
@@ -2,6 +2,7 @@
import * as nip19 from "nostr-tools/nip19"
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util"
import {loadMessagingRelayList} from "@welshman/app"
@@ -18,11 +19,11 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {goToChat} from "@app/util/routes"
import {makeChatPath} from "@app/util/routes"
const back = () => history.back()
const onSubmit = () => goToChat(pubkeys)
const onSubmit = () => goto(makeChatPath(pubkeys))
const addPubkey = (pubkey: string) => {
pubkeys = uniq([...pubkeys, pubkey])
+1 -1
View File
@@ -20,7 +20,7 @@
const title = getTagValue("title", event.tags)
const h = getTagValue("h", event.tags)
const images = new Set(getTagValues("image", event.tags))
const images = getTagValues("image", event.tags)
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
</script>
+11 -11
View File
@@ -28,42 +28,42 @@
<Profile inert pubkey={$pubkey} />
</Link>
{/if}
<div class="grid grid-cols-3 gap-3 w-full">
<div class="grid grid-cols-2 gap-3 w-full">
<Link
replaceState
href="/settings/alerts"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Bell} size={5} />
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Bell} size={7} />
Alerts
</Link>
{#if Capacitor.getPlatform() !== "ios"}
<Link
replaceState
href="/settings/wallet"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Wallet} size={5} />
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Wallet} size={7} />
Wallet
</Link>
{/if}
<Link
replaceState
href="/settings/relays"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Server} size={5} />
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Server} size={7} />
Relays
</Link>
<Link
replaceState
href="/settings/content"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={GalleryMinimalistic} size={5} />
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={GalleryMinimalistic} size={7} />
Content
</Link>
<Link
replaceState
href="/settings/privacy"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Shield} size={5} />
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Shield} size={7} />
Privacy
</Link>
</div>
-1
View File
@@ -42,7 +42,6 @@
target: element,
props: {
onClose: closeModal,
noEscape: options.noEscape,
fullscreen: options.fullscreen,
children: {component, props},
},
@@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {notificationSettings} from "@app/core/state"
import {onNotification} from "@app/util/push"
import {onNotification} from "@app/util/notifications"
let audioElement: HTMLAudioElement
+6 -8
View File
@@ -3,6 +3,7 @@
import {userProfile} from "@welshman/app"
import Letter from "@assets/icons/letter.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
import Planet from "@assets/icons/planet-3.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl"
@@ -14,7 +15,7 @@
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications"
import {goToChat} from "@app/util/routes"
import {goToLastChat} from "@app/util/routes"
type Props = {
children?: Snippet
@@ -22,8 +23,6 @@
const {children}: Props = $props()
const chatHandler = () => goToChat()
const showSettingsMenu = () => pushModal(MenuSettings)
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
@@ -50,7 +49,7 @@
</PrimaryNavItem>
<PrimaryNavItem
title="Messages"
onclick={chatHandler}
onclick={goToLastChat}
class="tooltip-right"
notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} />
@@ -72,13 +71,12 @@
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2">
<div class="flex gap-2 sm:gap-6">
<PrimaryNavItem title="Search" href="/people">
<ImageIcon alt="Search" src={Magnifier} size={8} />
<PrimaryNavItem title="Home" href="/home">
<ImageIcon alt="Home" src={HomeSmile} size={8} />
</PrimaryNavItem>
<PrimaryNavItem
title="Messages"
href="/chat"
onclick={chatHandler}
onclick={goToLastChat}
notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem>
+9 -19
View File
@@ -27,33 +27,23 @@
const handle = deriveHandleForPubkey(pubkey)
const openProfile = () => {
pushModal(ProfileDetail, {pubkey, url})
if (!inert) {
pushModal(ProfileDetail, {pubkey, url})
}
}
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
</script>
<div class="flex max-w-full items-start gap-3">
{#if inert}
<span class="py-1">
<ProfileCircle {pubkey} size={avatarSize} />
</span>
{:else}
<Button onclick={openProfile} class="py-1">
<ProfileCircle {pubkey} size={avatarSize} />
</Button>
{/if}
<Button onclick={openProfile} class="py-1">
<ProfileCircle {pubkey} size={avatarSize} />
</Button>
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
{#if inert}
<span class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</span>
{:else}
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</Button>
{/if}
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</Button>
<WotScore {pubkey} />
</div>
{#if $handle}
+9 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {removeUndefined} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util"
import {
@@ -29,10 +30,9 @@
import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {goToChat} from "@app/util/routes"
import {makeChatPath} from "@app/util/routes"
export type Props = {
pubkey: string
@@ -51,9 +51,11 @@
const back = () => history.back()
const chatPath = makeChatPath([pubkey])
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
const openChat = () => goToChat([pubkey])
const openChat = () => goto(chatPath)
const toggleMenu = (pubkey: string) => {
showMenu = !showMenu
@@ -83,7 +85,10 @@
})
const restoreMember = async () => {
const error = await addSpaceMembers(url!, [pubkey])
const {error} = await manageRelay(url!, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
-6
View File
@@ -6,7 +6,6 @@
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import DangerTriangle from "@assets/icons/danger-triangle.svg?dataurl"
import {errorMessage} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -71,11 +70,6 @@
Remove
</Button>
</RelayItem>
{:else}
<p class="text-center py-12 flex justify-center items-center gap-2">
<Icon icon={DangerTriangle} />
No relay selections found.
</p>
{/each}
</ModalBody>
<ModalFooter>
@@ -1,26 +1,27 @@
<script lang="ts" module>
export type ActionItem = {
title: string
subtitle: string
action: string
apply: () => unknown
}
</script>
<script lang="ts">
import Stars from "@assets/icons/stars.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import type {HealthCheck} from "@app/util/health"
import {applyHealthCheck} from "@app/util/health"
type Props = {
healthCheck: HealthCheck
}
const {healthCheck}: Props = $props()
const apply = () => applyHealthCheck(healthCheck)
const {title, action, subtitle, apply}: ActionItem = $props()
</script>
<div class="card2 card2-sm bg-alt flex justify-between">
<div class="flex flex-col gap-1">
<strong>{healthCheck.title}</strong>
<p class="text-sm">{healthCheck.description}</p>
<strong>{title}</strong>
<p class="text-sm">{subtitle}</p>
</div>
<Button class="btn btn-neutral btn-sm" onclick={apply}>
<Icon icon={Stars} />
{healthCheck.action}
{action}
</Button>
</div>
@@ -0,0 +1,58 @@
<script lang="ts">
import type {Readable} from "svelte/store"
import Stars from "@assets/icons/stars.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import DangerTriangle from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import type {ActionItem} from "@app/components/RelaySettingsActionItem.svelte"
import RelaySettingsActionItem from "@app/components/RelaySettingsActionItem.svelte"
interface Props {
actionItems: Readable<ActionItem[]>
}
const {actionItems}: Props = $props()
const back = () => history.back()
const applyAll = () => {
for (const actionItem of $actionItems) {
actionItem.apply()
}
}
$effect(() => {
if ($actionItems.length === 0) {
back()
}
})
</script>
<Modal>
<ModalBody>
<div class="flex gap-2 items-center">
<Icon icon={DangerTriangle} />
<strong class="text-lg">Action Items</strong>
</div>
<p class="text-sm">
Below are recommendations for adjustments to your relay selections that you might consider.
</p>
{#each $actionItems as actionItem}
<RelaySettingsActionItem {...actionItem} />
{/each}
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go Back
</Button>
<Button class="btn btn-primary" onclick={applyAll}>
<Icon icon={Stars} />
Apply All Recommendations
</Button>
</ModalFooter>
</Modal>
@@ -1,43 +0,0 @@
<script lang="ts">
import Stars from "@assets/icons/stars.svg?dataurl"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import DangerTriangle from "@assets/icons/danger-triangle.svg?dataurl"
import Stethoscope from "@assets/icons/stethoscope.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import RelaySettingsHealthCheck from "@app/components/RelaySettingsHealthCheck.svelte"
import {PLATFORM_NAME} from "@app/core/state"
import {pendingHealthChecks, applyHealthCheck} from "@app/util/health"
const applyAll = () => {
for (const healthCheck of $pendingHealthChecks) {
applyHealthCheck(healthCheck)
}
}
</script>
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
<div class="flex justify-between items-center">
<strong class="flex items-center gap-3 text-lg">
<Icon icon={Stethoscope} />
Health Check
</strong>
<span class="flex items-center gap-2 text-sm">
<Icon icon={$pendingHealthChecks.length === 0 ? CheckCircle : DangerTriangle} />
{$pendingHealthChecks.length} Issue{$pendingHealthChecks.length === 1 ? "" : "s"} Detected
</span>
</div>
<p>
{PLATFORM_NAME} actively checks your connection to the network in the background to discover relays
that are offline, that you don't have access to, or are otherwise causing trouble.
</p>
{#each $pendingHealthChecks as healthCheck}
<RelaySettingsHealthCheck {healthCheck} />
{/each}
{#if $pendingHealthChecks.length > 0}
<Button class="btn btn-primary" onclick={applyAll}>
<Icon icon={Stars} />
Apply All Recommendations
</Button>
{/if}
</div>
+2 -2
View File
@@ -26,7 +26,7 @@
const back = () => history.back()
const onResolved = () => {
const onDelete = () => {
if ($reports.size === 0) {
back()
}
@@ -40,7 +40,7 @@
<ModalSubtitle>All reports for this event are shown below.</ModalSubtitle>
</ModalHeader>
{#each $reports.values() as report (report.id)}
<ReportItem {url} event={report} {onResolved} />
<ReportItem {url} event={report} {onDelete} />
{/each}
</ModalBody>
<ModalFooter>
+3 -3
View File
@@ -15,10 +15,10 @@
type Props = {
url: string
event: TrustedEvent
onResolved?: () => void
onDelete?: () => void
}
const {url, event, onResolved}: Props = $props()
const {url, event, onDelete}: Props = $props()
const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags)
@@ -45,7 +45,7 @@
{/if}
</span>
</div>
<ReportMenu {url} {event} {onResolved} />
<ReportMenu {url} {event} {onDelete} />
</div>
{#if event.content}
<div class="border-l-2 border-primary pl-3">
+6 -6
View File
@@ -20,10 +20,10 @@
type Props = {
url: string
event: TrustedEvent
onResolved?: () => void
onDelete?: () => void
}
const {url, event, onResolved}: Props = $props()
const {url, event, onDelete}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
@@ -40,7 +40,7 @@
const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect})
onResolved?.()
onDelete?.()
}
const dismissReport = async () => {
@@ -54,7 +54,7 @@
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
onResolved?.()
onDelete?.()
}
}
@@ -77,7 +77,7 @@
repository.removeEvent(event.id)
repository.removeEvent(id)
history.back()
setTimeout(() => onResolved?.())
setTimeout(() => onDelete?.())
}
},
})
@@ -101,7 +101,7 @@
pushToast({message: "User has successfully been banned!"})
repository.removeEvent(event.id)
history.back()
setTimeout(() => onResolved?.())
setTimeout(() => onDelete?.())
}
},
})
+1 -1
View File
@@ -208,7 +208,7 @@
</div>
<div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isClosed} />
<span class="text-sm opacity-75">Membership requires approval</span>
<span class="text-sm opacity-75">Ignore requests to join</span>
</div>
</ModalBody>
{@render footer({loading})}
+9 -1
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Volume from "@assets/icons/volume.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import {deriveRoom} from "@app/core/state"
import {currentVoiceSession} from "@app/voice"
interface Props {
h: string
@@ -16,11 +18,17 @@
const room = deriveRoom(url, h)
const isVoiceRoom = $derived($room.livekit)
const isVoiceRoomActive = $derived(
$currentVoiceSession?.url === url && $currentVoiceSession?.h === h,
)
</script>
{#if isVoiceRoom}
<div class="flex shrink-0 items-center gap-1.5">
<Icon size={size + 1} icon={Volume} />
<Icon
size={size + 1}
icon={isVoiceRoomActive ? VolumeLoud : Volume}
class={isVoiceRoomActive ? "text-primary -translate-x-0.5" : ""} />
{#if $room.picture}
<span class="text-base">/</span>
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
-83
View File
@@ -1,83 +0,0 @@
<script lang="ts">
import {getTagValue, ManagementMethod} from "@welshman/util"
import type {TrustedEvent, PublishedRoomMeta} from "@welshman/util"
import {repository, manageRelay} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {deriveRoom} from "@app/core/state"
import {addRoomMembers} from "@app/core/commands"
type Props = {
url: string
event: TrustedEvent
onResolved?: () => void
}
const {url, event, onResolved}: Props = $props()
const h = getTagValue("h", event.tags) || ""
const room = deriveRoom(url, h)
const showProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const dismiss = async () => {
loading = true
try {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [event.id, "Join request dismissed"],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Join request has been dismissed."})
repository.removeEvent(event.id)
onResolved?.()
}
} finally {
loading = false
}
}
const accept = async () => {
loading = true
try {
const error = await addRoomMembers(url, $room as PublishedRoomMeta, [event.pubkey])
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Member has been added to the room!"})
onResolved?.()
}
} finally {
loading = false
}
}
let loading = $state(false)
</script>
<div class="column gap-4 card2 card2-sm bg-alt">
<div class="flex justify-between gap-2">
<div>
<Button class="inline text-primary" onclick={showProfile}>
<ProfileName pubkey={event.pubkey} {url} />
</Button>
<span>
requested membership in #<RoomName {url} {h} />
</span>
</div>
<div class="flex gap-2">
<Button class="btn btn-neutral btn-sm" onclick={dismiss} disabled={loading}>Dismiss</Button>
<Button class="btn btn-primary btn-sm" onclick={accept} disabled={loading}>Accept</Button>
</div>
</div>
</div>
+30 -9
View File
@@ -2,8 +2,9 @@
import {onMount} from "svelte"
import {setKey, popKey} from "@lib/implicit"
import {sleep} from "@welshman/lib"
import {displayProfileByPubkey} from "@welshman/app"
import type {PublishedRoomMeta} from "@welshman/util"
import {ManagementMethod} from "@welshman/util"
import {manageRelay} from "@welshman/app"
import {addRoomMember, displayProfileByPubkey, waitForThunkError} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -21,7 +22,6 @@
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
import {deriveRoom, deriveSpaceMembers} from "@app/core/state"
import {addRoomMembers} from "@app/core/commands"
interface Props {
url: string
@@ -42,14 +42,35 @@
// Show loading for auto submit callback
await sleep(500)
const error = await addRoomMembers(url, $room as PublishedRoomMeta, pubkeys)
const results = await Promise.all(
pubkeys
.filter(pubkey => !$spaceMembers.includes(pubkey))
.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Members have successfully been added!"})
back()
for (const {error} of results) {
if (error) {
return pushToast({theme: "error", message: error})
}
}
const errors = await Promise.all(
pubkeys.map(pubkey => waitForThunkError(addRoomMember(url, $room, pubkey))),
)
for (const error of errors) {
if (error) {
return pushToast({theme: "error", message: errors[0]})
}
}
pushToast({message: "Members have successfully been added!"})
back()
} finally {
loading = false
}
@@ -1,58 +0,0 @@
<script lang="ts">
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ReportItem from "@app/components/ReportItem.svelte"
import RoomJoinItem from "@app/components/RoomJoinItem.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {REPORT} from "@welshman/util"
import {deriveSpaceActionItems} from "@app/core/state"
interface Props {
url: string
}
const {url}: Props = $props()
const actionItems = deriveSpaceActionItems(url)
const back = () => history.back()
const onResolved = () => {
if ($actionItems.length === 0) {
back()
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Action Items</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-2">
{#each $actionItems as event (event.id)}
{#if event.kind === REPORT}
<ReportItem {url} {event} {onResolved} />
{:else}
<RoomJoinItem {url} {event} {onResolved} />
{/if}
{:else}
<p class="py-12 text-center">No action items found.</p>
{/each}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</Modal>
+1 -1
View File
@@ -60,7 +60,7 @@
} else {
const permissions = await Push.request()
if (permissions.startsWith("granted")) {
if (permissions === "granted") {
await setSpaceNotifications(url, true)
}
}
+1 -1
View File
@@ -48,7 +48,7 @@
} else {
const permissions = await Push.request()
if (permissions.startsWith("granted")) {
if (permissions === "granted") {
await setSpaceNotifications(url, true)
}
}
+17 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
import {manageRelay} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -12,7 +13,6 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
interface Props {
@@ -27,14 +27,23 @@
loading = true
try {
const error = await addSpaceMembers(url, pubkeys)
const results = await Promise.all(
pubkeys.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Members have successfully been added!"})
back()
for (const {error} of results) {
if (error) {
return pushToast({theme: "error", message: error})
}
}
pushToast({message: "Members have successfully been added!"})
back()
} finally {
loading = false
}
+4 -2
View File
@@ -17,7 +17,6 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
interface Props {
@@ -56,7 +55,10 @@
}
const restoreMember = async (pubkey: string) => {
const error = await addSpaceMembers(url, [pubkey])
const {error} = await manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
+35 -40
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {derived} from "svelte/store"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, REPORT} from "@welshman/util"
import {deriveRelay, createSearch, pubkey} from "@welshman/app"
import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -34,7 +35,7 @@
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte"
import SpaceMembers from "@app/components/SpaceMembers.svelte"
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import SpaceReports from "@app/components/SpaceReports.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
@@ -51,15 +52,13 @@
deriveUserCanCreateRoom,
deriveUserIsSpaceAdmin,
deriveEventsForUrl,
deriveSpaceActionItems,
notificationSettings,
deriveShouldNotify,
displayRoom,
} from "@app/core/state"
import {setSpaceNotifications} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {makeSpacePath, goToChat} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
import {makeSpacePath, makeChatPath} from "@app/util/routes"
const {url} = $props()
@@ -74,7 +73,7 @@
const otherVoiceRooms = deriveOtherVoiceRooms(url)
const members = deriveSpaceMembers(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const actionItems = deriveSpaceActionItems(url)
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
const spaceKinds = derived(
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
@@ -99,23 +98,21 @@
showMenu = !showMenu
}
const showDetail = () => pushModal(SpaceDetail, {url})
const showDetail = () => pushModal(SpaceDetail, {url}, {replaceState})
const showMembers = () => pushModal(SpaceMembers, {url})
const showMembers = () => pushModal(SpaceMembers, {url}, {replaceState})
const showActionItems = () => pushModal(SpaceActionItems, {url})
const showReports = () => pushModal(SpaceReports, {url}, {replaceState})
const canCreateRoom = deriveUserCanCreateRoom(url)
const createInvite = () => pushModal(SpaceInvite, {url})
const createInvite = () => pushModal(SpaceInvite, {url}, {replaceState})
const leaveSpace = () => pushModal(SpaceExit, {url})
const leaveSpace = () => pushModal(SpaceExit, {url}, {replaceState})
const joinSpace = () => pushModal(SpaceJoin, {url})
const joinSpace = () => pushModal(SpaceJoin, {url}, {replaceState})
const addRoom = () => pushModal(RoomCreate, {url})
const contactOwner = () => goToChat([$relay!.pubkey!])
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
const shouldNotify = deriveShouldNotify(url)
@@ -131,22 +128,23 @@
let term = $state("")
let showMenu = $state(false)
let replaceState = $state(false)
let element: Element | undefined = $state()
onMount(() => {
replaceState = Boolean(element?.closest(".drawer"))
})
</script>
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
<div class="flex-shrink-0">
<Button
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
class="flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}>
<div class="flex items-center justify-between">
<strong class="flex items-center gap-1 relative">
<RelayName {url} class="ellipsize" />
<div
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
class:opacity-100={$userIsAdmin && $actionItems.length > 0}>
</div>
<strong class="ellipsize flex items-center gap-1">
<RelayName {url} />
{#if $notificationSettings.push && !$shouldNotify}
<Icon icon={BellOff} size={3} class="opacity-50" />
{/if}
@@ -180,21 +178,18 @@
</li>
{#if $userIsAdmin}
<li>
<Button onclick={showActionItems}>
<Button onclick={showReports}>
<Icon icon={Danger} />
Action Items ({$actionItems.length})
{#if $actionItems.length > 0}
<div class="h-2 w-2 rounded-full bg-primary"></div>
{/if}
View Reports ({$reports.length})
</Button>
</li>
{/if}
{#if $relay?.pubkey && $relay.pubkey !== $pubkey}
<li>
<Button onclick={contactOwner}>
<Link href={makeChatPath([$relay.pubkey])}>
<Icon icon={Letter} />
Contact Owner
</Button>
</Link>
</li>
{/if}
<li>
@@ -229,31 +224,31 @@
</div>
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
{#if hasNip29($relay)}
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
<SecondaryNavItem {replaceState} href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity
</SecondaryNavItem>
{:else}
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
<SecondaryNavItem {replaceState} href={chatPath}>
<Icon icon={ChatRound} /> Chat
</SecondaryNavItem>
{/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem href={goalsPath}>
<SecondaryNavItem {replaceState} href={goalsPath}>
<Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(THREAD)}
<SecondaryNavItem href={threadsPath}>
<SecondaryNavItem {replaceState} href={threadsPath}>
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(CLASSIFIED)}
<SecondaryNavItem href={classifiedsPath}>
<SecondaryNavItem {replaceState} href={classifiedsPath}>
<Icon icon={CaseMinimalistic} /> Classifieds
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(EVENT_TIME)}
<SecondaryNavItem href={calendarPath}>
<SecondaryNavItem {replaceState} href={calendarPath}>
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
@@ -263,7 +258,7 @@
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2 flex-shrink-0"></div>
@@ -282,17 +277,17 @@
</label>
{/if}
{#each $roomSearch.searchValues(term) as h (h)}
<SpaceMenuRoomItem {url} {h} />
<SpaceMenuRoomItem {replaceState} {url} {h} />
{/each}
{#if $otherVoiceRooms.length > 0}
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
<SpaceMenuRoomItem {replaceState} {url} {h} />
{/each}
{/if}
{#if $canCreateRoom}
<SecondaryNavItem onclick={addRoom}>
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon={AddCircle} />
Create room
</SecondaryNavItem>
@@ -302,7 +297,7 @@
</div>
</SecondaryNavSection>
<div
class="flex flex-shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+3rem)] md:pb-2 z-nav">
class="flex flex-shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+3rem)] sm:pb-2 z-nav">
<VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
<SocketStatusIndicator {url} />
+7 -4
View File
@@ -12,10 +12,11 @@
interface Props {
url: any
h: any
notify?: boolean
replaceState?: boolean
}
const {url, h, replaceState = false}: Props = $props()
const {url, h, notify = false, replaceState = false}: Props = $props()
const room = deriveRoom(url, h)
const roomType = $derived(getRoomType($room))
@@ -23,13 +24,15 @@
const shouldNotifyForSpace = deriveShouldNotify(url)
const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
</script>
{#if roomType === RoomType.Voice}
<VoiceRoomItem {url} {h} {replaceState} {notification} />
<VoiceRoomItem {url} {h} {replaceState} />
{:else}
<SecondaryNavItem href={path} {replaceState} {notification}>
<SecondaryNavItem
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
<RoomNameWithImage {url} {h} />
{#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
+2 -2
View File
@@ -23,7 +23,7 @@
const back = () => history.back()
const onResolved = () => {
const onDelete = () => {
if ($reports.length === 0) {
back()
}
@@ -38,7 +38,7 @@
</ModalHeader>
<div class="flex flex-col gap-2">
{#each $reports as event (event.id)}
<ReportItem {url} {event} {onResolved} />
<ReportItem {url} {event} {onDelete} />
{:else}
<p class="py-12 text-center">No reports found.</p>
{/each}
+247
View File
@@ -0,0 +1,247 @@
<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 VideoCallVideo from "@app/components/VideoCallVideo.svelte"
import {
currentVoiceSession,
currentVoiceRoom,
videoCallContentActive,
videoCallLayoutRevision,
videoPrimaryTileKey,
toggleVideoPrimaryTile,
pubkeyFromLiveKitIdentity,
} from "@app/voice"
type Variant = "mobile" | "desktop-split" | "desktop-full"
type Props = {
variant: Variant
url: string
h: string
visible?: boolean
class?: string
}
type Tile = {
identity: string
isLocal: boolean
trackSid: string
attachable: Track | undefined
source: Track.Source.Camera | Track.Source.ScreenShare
}
type TileLayout = "spotlight" | "default" | "strip"
const {variant, url, h, visible = true, class: className = ""}: Props = $props()
const roomMatches = $derived($currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h)
const allowEmptyPanel = $derived(variant === "desktop-split" || variant === "desktop-full")
const showPanel = $derived(
visible &&
roomMatches &&
(variant === "mobile" ? $videoCallContentActive : $videoCallContentActive || allowEmptyPanel),
)
const tiles = $derived.by(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes
$videoCallLayoutRevision
const session = $currentVoiceSession
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
return []
}
const room = session.room
const out: Tile[] = []
const lp = room.localParticipant
if (session.cameraOn) {
const localPub = lp.getTrackPublication(Track.Source.Camera)
out.push({
identity: lp.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-camera",
attachable: localPub?.track,
source: Track.Source.Camera,
})
}
if (session.screenShareOn) {
const localPub = lp.getTrackPublication(Track.Source.ScreenShare)
out.push({
identity: lp.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-screen",
attachable: 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) {
out.push({
identity: rp.identity,
isLocal: false,
trackSid: camPub.trackSid,
attachable: camPub.track,
source: Track.Source.Camera,
})
}
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
if (screenPub?.isSubscribed && screenPub.track) {
out.push({
identity: rp.identity,
isLocal: false,
trackSid: screenPub.trackSid,
attachable: screenPub.track,
source: Track.Source.ScreenShare,
})
}
}
return out
})
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
const tileKey = (t: Tile) => `${t.identity}\x1f${t.source}`
const primaryTile = $derived.by(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return undefined
return tiles.find(t => tileKey(t) === k)
})
const secondaryTiles = $derived.by(() => {
const p = primaryTile
if (p === undefined) return tiles
const pk = tileKey(p)
return tiles.filter(t => tileKey(t) !== pk)
})
const useSpotlightLayout = $derived(primaryTile !== undefined)
const useMultiGrid = $derived(!useSpotlightLayout && tiles.length > 2)
$effect(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return
if (!tiles.some(t => tileKey(t) === k)) {
videoPrimaryTileKey.set(undefined)
}
})
$effect(() => {
for (const t of tiles) {
const pk = pubkeyFromLiveKitIdentity(t.identity)
if (pk) loadProfile(pk)
}
})
const labelFor = (identity: string, source: Tile["source"]) => {
const pk = pubkeyFromLiveKitIdentity(identity)
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
return source === Track.Source.ScreenShare ? `${name} · screen` : name
}
const showTileGrid = $derived(tiles.length > 0)
const spotlightHandlerFor = (key: string) => () => {
toggleVideoPrimaryTile(key)
}
const panelChrome = $derived(
cx(
variant === "mobile" &&
"cb ct cw z-compose bg-base-300/95 fixed inset-x-0 flex min-h-0 flex-col gap-2 overflow-hidden p-2 md:hidden",
variant === "desktop-split" &&
"cb ct cw-split-video z-compose bg-base-300/95 fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
variant === "desktop-full" &&
"cb ct cw z-compose bg-base-300/95 fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
className,
),
)
</script>
{#snippet videoTile(tile: Tile, 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",
$videoPrimaryTileKey === tileKey(tile) &&
"ring-2 ring-primary ring-offset-2 ring-offset-base-300",
)}>
{#if tile.attachable}
<VideoCallVideo
track={tile.attachable}
muted={tile.isLocal}
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
class="pointer-events-none absolute inset-0" />
{: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 tiles.length > 1}
<Button
data-tip={$videoPrimaryTileKey === tileKey(tile) ? "Exit spotlight" : "Spotlight"}
class="absolute right-1 top-1 z-20 btn btn-xs btn-circle btn-ghost bg-base-100/70"
onclick={spotlightHandlerFor(tileKey(tile))}>
<Icon icon={Pin} size={3} />
</Button>
{/if}
</div>
{/snippet}
{#if showPanel && (showTileGrid || allowEmptyPanel)}
<div class={panelChrome}>
{#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 tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{:else}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{#each tiles 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-100/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 in the voice widget to share video.
</p>
</div>
{/if}
</div>
{/if}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import type {Track} from "livekit-client"
import cx from "classnames"
type Props = {
track: Track
muted?: boolean
fit?: "cover" | "contain"
class?: string
}
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
let el = $state<HTMLVideoElement | undefined>()
$effect(() => {
const v = el
const t = track
if (!v) return
t.attach(v)
return () => {
t.detach(v)
}
})
</script>
<video
bind:this={el}
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
playsinline
{muted}></video>
+12 -15
View File
@@ -1,18 +1,15 @@
<script lang="ts">
import cx from "classnames"
import {goto} from "$app/navigation"
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {pushToast} from "@app/util/toast"
import {makeRoomPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import {makeRoomId} from "@app/core/state"
import {
VoiceState,
deriveVoiceParticipants,
joinVoiceRoom,
cancelJoinVoiceRoom,
currentVoiceRoom,
voiceState,
@@ -25,31 +22,32 @@
url: string
h: string
replaceState?: boolean
notification?: boolean
}
const {url, h, replaceState = false, notification = false}: Props = $props()
const {url, h, replaceState = false}: Props = $props()
const participants = deriveVoiceParticipants(url, h)
const isActive = $derived(
$voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h),
$voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
)
const isJoining = $derived(
$voiceState === VoiceState.Joining && $currentVoiceRoom?.id === makeRoomId(url, h),
$voiceState === "joining" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
)
const handleClick = async (e: MouseEvent) => {
const handleClick = async () => {
if (isActive) return
if (isJoining) {
e.preventDefault()
cancelJoinVoiceRoom()
return
}
e.preventDefault()
await goto(makeRoomPath(url, h), {replaceState})
pushModal(VoiceRoomJoinDialog, {url, h})
try {
await joinVoiceRoom(url, h)
} catch (e) {
console.error("Failed to join voice room", e)
pushToast({theme: "error", message: "Failed to join voice room"})
}
}
$effect(() => {
@@ -62,7 +60,6 @@
<SecondaryNavItem
href={makeRoomPath(url, h)}
{replaceState}
{notification}
onclick={handleClick}
class={cx("!items-start", isActive && "!bg-base-100 !text-base-content")}>
<div class="flex w-full min-w-0 flex-col gap-2">
@@ -1,115 +0,0 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Volume from "@assets/icons/volume.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {displayRoom} from "@app/core/state"
import {joinVoiceRoom} from "@app/voice"
import {popModal} from "@app/util/modal"
type Props = {
url: string
h: string
}
const {url, h}: Props = $props()
const spaceLabel = $derived(displayRelayUrl(url))
let audioInputs = $state<MediaDeviceInfo[]>([])
let selectedDeviceId = $state("")
let startWithoutMic = $state(false)
const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return
try {
const devices = await navigator.mediaDevices.enumerateDevices()
audioInputs = devices.filter(d => d.kind === "audioinput")
} catch {
audioInputs = []
}
}
$effect(() => {
void loadDevices()
})
const goBack = () => history.back()
const joinVoice = async () => {
popModal()
await joinVoiceRoom(
url,
h,
startWithoutMic,
startWithoutMic ? undefined : selectedDeviceId || undefined,
)
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Join voice room?</ModalTitle>
<ModalSubtitle>
<span class="inline-flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1">
<Icon icon={Volume} size={4} class="shrink-0" />
<span class="ellipsize min-w-0">{displayRoom(url, h)}</span>
<span>·</span>
<span>{spaceLabel}</span>
</span>
</ModalSubtitle>
</ModalHeader>
<p class="text-sm opacity-80">Select a microphone to join the call:</p>
<div class="flex flex-col gap-4 pt-2">
<div class="flex items-center gap-2">
<input
id="voice-start-without-mic"
type="checkbox"
class="checkbox"
bind:checked={startWithoutMic} />
<label for="voice-start-without-mic" class="text-sm cursor-pointer">
Join without microphone (you can unmute later)
</label>
</div>
<FieldInline>
{#snippet label()}
<p>Microphone</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={selectedDeviceId}
disabled={startWithoutMic}
aria-label="Microphone">
<option value="">Default microphone</option>
{#each audioInputs as d (d.deviceId)}
<option value={d.deviceId}>
{d.label || `Microphone ${d.deviceId.slice(0, 8)}`}
</option>
{/each}
</select>
{/snippet}
</FieldInline>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={goBack}>
<Icon icon={AltArrowLeft} />
Don't join
</Button>
<Button class="btn btn-primary" onclick={joinVoice}>
Join voice
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</Modal>
+32 -51
View File
@@ -1,79 +1,44 @@
<script lang="ts">
import {readable} from "svelte/store"
import {fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
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 CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import {displayRoom} from "@app/core/state"
import {
decodeRelay,
deriveRoom,
displayRoom,
getRoomType,
RoomType,
type Room,
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {makeRoomPath} from "@app/util/routes"
import {
VoiceState,
currentVoiceSession,
currentVoiceRoom,
voiceState,
leaveVoiceRoom,
toggleMute,
toggleCamera,
toggleScreenShare,
rejoinVoiceRoom,
cancelJoinVoiceRoom,
} from "@app/voice"
const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined)
const displayedRoomStore = $derived(
url && h && typeof h === "string" ? deriveRoom(url, h) : readable(undefined),
const roomName = $derived(
$currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "",
)
const routeDisplayedRoom = $derived($displayedRoomStore)
const targetRoom = $derived.by((): Room | undefined => {
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
return $currentVoiceRoom
}
if ($voiceState === VoiceState.Disconnected) {
if (routeDisplayedRoom) {
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
return routeDisplayedRoom
}
return undefined
}
return $currentVoiceRoom
}
return $currentVoiceRoom
})
const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "")
const spaceName = $derived(targetRoom ? displayRelayUrl(targetRoom.url) : "")
const openJoinDialog = async () => {
if (!targetRoom) return
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
}
const spaceName = $derived($currentVoiceRoom ? displayRelayUrl($currentVoiceRoom.url) : "")
</script>
{#if targetRoom}
{#if $currentVoiceRoom}
<div
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}
{#if $voiceState === "joining"}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === VoiceState.Connected}
{:else if $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>
@@ -83,7 +48,7 @@
</span>
</div>
<div class="flex items-center gap-1">
{#if $voiceState === VoiceState.Joining}
{#if $voiceState === "joining"}
<span class="loading loading-spinner loading-sm"></span>
<Button
data-tip="Cancel"
@@ -91,7 +56,7 @@
onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={4} />
</Button>
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
{:else if $voiceState === "connected" && $currentVoiceSession}
<Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
@@ -100,6 +65,22 @@
onclick={toggleMute}>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
</Button>
<Button
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.cameraOn
? 'btn-ghost'
: 'btn-error'}"
onclick={toggleCamera}>
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
</Button>
<Button
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.screenShareOn
? 'btn-ghost'
: 'btn-error'}"
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4} />
</Button>
<Button
data-tip="Leave room"
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
@@ -110,7 +91,7 @@
<Button
data-tip="Join Voice"
class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
onclick={openJoinDialog}>
onclick={rejoinVoiceRoom}>
<Icon icon={PhoneCallingRounded} size={4} />
</Button>
{/if}
+5 -68
View File
@@ -17,7 +17,7 @@ import {
} from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor"
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
import type {TrustedEvent, EventContent, Profile} from "@welshman/util"
import {
DELETE,
REPORT,
@@ -52,7 +52,6 @@ import {
editProfile,
createProfile,
uniqTags,
ManagementMethod,
} from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
@@ -73,9 +72,6 @@ import {
getPubkeyRelays,
userBlossomServerList,
getThunkError,
addRoomMember,
manageRelay,
getRelay,
} from "@welshman/app"
import {compressFile} from "@lib/html"
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
@@ -93,7 +89,6 @@ import {
stripPrefix,
relaysMostlyRestricted,
deriveSocket,
deriveSpaceMembers,
} from "@app/core/state"
// Utils
@@ -225,7 +220,8 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
}
}
const error = await waitForThunkError(publishJoinRequest({url, claim}))
const thunk = publishJoinRequest({url, claim})
const error = await waitForThunkError(thunk)
if (shouldIgnoreError(error)) return
if (!claim && error.includes("invite code size")) return
@@ -554,12 +550,6 @@ export const createInvoice = async ({
export const normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http"))
export const fetchHasBlossomSupport = async (url: string) => {
const relay = getRelay(url)
if (relay?.supported_nips?.map(String).includes("BUD-02")) {
return true
}
const server = normalizeBlossomUrl(url)
const $signer = signer.get() || Nip01Signer.ephemeral()
const headers: Record<string, string> = {
@@ -649,19 +639,13 @@ export const uploadFile = async (file: File, options: UploadFileOptions = {}) =>
const res = await uploadBlob(server, file, {authEvent})
const text = await res.text()
let task
try {
task = parseJson(text)
} catch (e) {
return {error: text}
}
let {uploaded, url, ...task} = parseJson(text) || {}
if (!task?.uploaded) {
if (!uploaded) {
return {error: text || `Failed to upload file (HTTP ${res.status})`}
}
// Always append correct file extension if we encrypted the file, or if it's missing
let url = task.url
if (options.encrypt) {
url = url.replace(/\.\w+$/, "") + ext
} else if (new URL(url).pathname.split(".").length === 1) {
@@ -715,50 +699,3 @@ export const updateProfile = ({
return publishThunk({event, relays})
}
// Admin actions
export const addSpaceMembers = async (
url: string,
pubkeys: string[],
): Promise<string | undefined> => {
const spaceMembers = get(deriveSpaceMembers(url))
const results = await Promise.all(
pubkeys
.filter(pubkey => !spaceMembers.includes(pubkey))
.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
for (const {error} of results) {
if (error) {
return error
}
}
}
export const addRoomMembers = async (
url: string,
room: PublishedRoomMeta,
pubkeys: string[],
): Promise<string | undefined> => {
const error = await addSpaceMembers(url, pubkeys)
if (error) {
return error
}
const errors = await Promise.all(
pubkeys.map(pubkey => waitForThunkError(addRoomMember(url, room, pubkey))),
)
for (const error of errors) {
if (error) {
return error
}
}
}
+10 -14
View File
@@ -22,7 +22,6 @@ import {
getAddress,
isShareableRelayUrl,
getRelaysFromList,
sortEventsDesc,
} from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util"
import {load, request} from "@welshman/net"
@@ -48,11 +47,11 @@ export const makeFeed = ({
onForwardExhausted?: () => void
at?: number
}) => {
const interval = int(WEEK)
const controller = new AbortController()
const events = writable<TrustedEvent[]>([])
let interval = int(WEEK)
let buffer = sortEventsDesc(getEventsForUrl(url, filters))
let buffer: TrustedEvent[] = []
let backwardWindow = [at - interval, at]
let forwardWindow = [at, at + interval]
@@ -112,20 +111,13 @@ export const makeFeed = ({
}),
]
const loadTimeframe = async (since: number, until: number) => {
const events = await request({
const loadTimeframe = (since: number, until: number) => {
request({
relays: [url],
autoClose: true,
signal: controller.signal,
filters: filters.map(filter => ({...filter, since, until})),
})
// If we found nothing, accelerate
if (events.length === 0) {
interval = Math.round(interval * 1.1)
} else {
interval = int(WEEK)
}
}
const backwardScroller = createScroller({
@@ -137,7 +129,7 @@ export const makeFeed = ({
backwardWindow = [since - interval, since]
for (const event of buffer.splice(0, 30)) {
for (const event of buffer.splice(0)) {
insertEvent(event)
}
@@ -160,7 +152,7 @@ export const makeFeed = ({
forwardWindow = [until, until + interval]
for (const event of buffer.splice(0, 30)) {
for (const event of buffer.splice(0)) {
insertEvent(event)
}
@@ -173,6 +165,10 @@ export const makeFeed = ({
},
})
for (const event of getEventsForUrl(url, filters)) {
insertEvent(event)
}
return {
events,
cleanup: () => {
+6 -57
View File
@@ -31,7 +31,6 @@ import {
groupBy,
remove,
simpleCache,
removeUndefined,
} from "@welshman/lib"
import type {Override} from "@welshman/lib"
import type {RepositoryUpdate} from "@welshman/net"
@@ -100,7 +99,6 @@ import {
REPOST,
GENERIC_REPOST,
asDecryptedEvent,
getTagValue,
getGroupTags,
getListTags,
getPubkeyTagValues,
@@ -113,7 +111,6 @@ import {
readRoomMeta,
makeRoomMeta,
ManagementMethod,
sortEventsAsc,
sortEventsDesc,
getAddress,
Address,
@@ -290,7 +287,7 @@ export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) =
derived(
[deriveRelay(url), deriveEventsForUrl(url, filters)],
([relay, events]) => events,
// TODO: khatru doesn't support relay.self, uncomment when it's ready
// khatru doesn't support relay.self, uncomment when it's ready
// filter(spec({pubkey: relay.self}), events)
)
@@ -431,11 +428,10 @@ export type PushSubscription = {
export type PushState = {
token?: string
useFallback?: boolean
subscription?: PushSubscription
}
export const pushState = withGetter(writable<PushState>({}))
export const notificationState = withGetter(writable<PushState>({}))
// Chats
@@ -546,11 +542,8 @@ export const chatsById = call(() => {
const unsubscribers = [
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
// Do this async so that profiles are populated
setTimeout(() => {
addEvents(added)
removeEvents(removed)
}, 50)
addEvents(added)
removeEvents(removed)
}),
]
@@ -672,7 +665,7 @@ export const deriveRoom = call(() => {
return (url: string, h: string) =>
derived(
_deriveRoom(makeRoomId(url, h)),
room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room,
room => room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})},
)
})
@@ -875,7 +868,7 @@ export const deriveRoomMembers = (url: string, h: string) => {
const members = new Set<string>()
for (const event of sortEventsAsc($events)) {
for (const event of sortBy(e => -e.created_at, $events)) {
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) {
@@ -909,50 +902,6 @@ export const deriveRoomAdmins = (url: string, h: string) => {
})
}
// Action items (admin review queue)
// const pendingJoins: TrustedEvent[] = []
export const deriveSpaceActionItems = (url: string) =>
derived(
deriveEventsForUrl(url, [
{
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS],
},
]),
$events => {
const getRoomId = (e: TrustedEvent) =>
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
const reports = $events.filter(e => e.kind === REPORT)
const pendingJoins: TrustedEvent[] = []
// Room-level join requests — most recent per pubkey+h
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 ?? [])
pendingJoins.push(
...removeUndefined(
Array.from(groupBy(e => e.pubkey, roomJoins).values())
.map(sortEventsDesc)
.map(first),
).filter(({pubkey, created_at}) => {
if (roomMembers.includes(pubkey)) return false
if (gt(roomMembersEvent?.created_at, created_at)) return false
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
return true
}),
)
}
return sortEventsDesc([...reports, ...pendingJoins])
},
)
// User membership status
export enum MembershipStatus {
-41
View File
@@ -1,5 +1,4 @@
import {call} from "@welshman/lib"
import {SecureStorage} from "@aparajita/capacitor-secure-storage"
import {Preferences} from "@capacitor/preferences"
import {IDB} from "@lib/indexeddb"
@@ -31,46 +30,6 @@ export const kv = call(() => {
return {get, set, clear}
})
export const ss = call(() => {
let p = Promise.resolve()
const get = async <T>(key: string): Promise<T | undefined> => {
let value = await SecureStorage.getItem(key)
if (!value) {
const legacy = await Preferences.get({key})
if (legacy.value) {
value = legacy.value
await SecureStorage.setItem(key, legacy.value)
await Preferences.remove({key})
}
}
if (!value) return undefined
try {
return JSON.parse(value)
} catch (e) {
return undefined
}
}
const set = async <T>(key: string, value: T): Promise<void> => {
p = p.then(() => SecureStorage.setItem(key, JSON.stringify(value)))
await p
}
const clear = async () => {
p = p.then(() => SecureStorage.clear())
await p
}
return {get, set, clear}
})
export const db = new IDB({
name: "flotilla-9gl",
version: 1,
+70 -64
View File
@@ -1,7 +1,7 @@
import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store"
import {derived, get} from "svelte/store"
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {last, call, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {
getListTags,
getRelayTagValues,
@@ -18,9 +18,8 @@ import {
RELAY_REMOVE_MEMBER,
isSignedEvent,
unionFilters,
getTagValue,
} from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util"
import type {Filter} from "@welshman/util"
import {request, requestOne, Difference, DifferenceEvent} from "@welshman/net"
import {
pubkey,
@@ -64,24 +63,12 @@ type SyncOpts = {
url: string
signal: AbortSignal
filters: Filter[]
onEvent?: (event: TrustedEvent) => void
}
const pullOneWithFallback = async (
url: string,
filter: Filter,
signal: AbortSignal,
onEvent?: (event: TrustedEvent) => void,
) => {
const pullOneWithFallback = async (url: string, filter: Filter, signal: AbortSignal) => {
const cachedEvents = repository.query([filter]).filter(isSignedEvent)
const since = last(cachedEvents.slice(10))?.created_at || 0
if (onEvent) {
for (const event of cachedEvents) {
onEvent(event)
}
}
const shouldFallback =
!hasNegentropy(url) ||
(await new Promise(resolve => {
@@ -93,7 +80,7 @@ const pullOneWithFallback = async (
diff.on(DifferenceEvent.Close, () => {
for (const ids of chunk(100, Array.from(diff.need))) {
requestOne({relay: url, signal, autoClose: true, filters: [{ids}], onEvent})
requestOne({relay: url, signal, autoClose: true, filters: [{ids}]})
}
resolve(false)
@@ -101,29 +88,29 @@ const pullOneWithFallback = async (
}))
if (shouldFallback && !signal.aborted) {
request({relays: [url], signal, autoClose: true, filters: [{since, ...filter}], onEvent})
request({relays: [url], signal, autoClose: true, filters: [{...filter, since}]})
}
}
export const pullWithFallback = async ({url, signal, filters, onEvent}: SyncOpts) => {
export const pullWithFallback = async ({url, signal, filters}: SyncOpts) => {
await loadRelay(url)
if (signal.aborted) return
for (const filter of filters) {
pullOneWithFallback(url, filter, signal, onEvent)
pullOneWithFallback(url, filter, signal)
}
}
const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
const listen = ({url, signal, filters}: SyncOpts) => {
const relays = [url]
request({relays, signal, filters: unionFilters(filters.map(assoc("limit", 0))), onEvent})
request({relays, signal, filters: unionFilters(filters.map(assoc("limit", 0)))})
}
const pullAndListen = (options: SyncOpts) => {
pullWithFallback(options)
listen(options)
const pullAndListen = ({url, filters, signal}: SyncOpts) => {
pullWithFallback({url, signal, filters})
listen({url, signal, filters})
}
// Relays
@@ -268,53 +255,72 @@ const syncUserData = () => {
// Spaces
const syncSpace = (url: string, rooms: string[]) => {
const since = ago(WEEK)
const seen = new Set<string>()
const controller = new AbortController()
const pullRoomContent = (room: string) => {
if (!seen.has(room)) {
seen.add(room)
pullAndListen({
url,
signal: controller.signal,
filters: [
{kinds: MESSAGE_KINDS, since, "#h": [room]},
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
],
})
}
}
// Relay-level kinds don't need #h tags
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
})
for (const room of rooms) {
pullRoomContent(room)
}
// Room metadata uses #d tags, not #h, so no filtering needed
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS]}],
})
const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
const roomMemberKinds = [ROOM_DELETE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
// Room-scoped kinds: add #h tags when we know which rooms the user is in.
// This avoids sending broad filters that picky relays reject.
const roomKinds = [ROOM_DELETE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
const since = ago(WEEK)
if (rooms.length > 0) {
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: roomKinds, "#h": rooms}],
})
pullAndListen({
url,
signal: controller.signal,
filters: [
{kinds: MESSAGE_KINDS, "#h": rooms, since},
makeCommentFilter(CONTENT_KINDS, {"#h": rooms, since}),
],
})
listen({
url,
signal: controller.signal,
filters: [{kinds: REACTION_KINDS, "#h": rooms}],
})
} else {
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: roomKinds}],
})
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: MESSAGE_KINDS, since}, makeCommentFilter(CONTENT_KINDS, {since})],
})
listen({
url,
signal: controller.signal,
filters: [{kinds: REACTION_KINDS}],
})
}
pullAndListen({
url,
signal: controller.signal,
filters: [
{kinds: relayKinds},
{kinds: roomMetaKinds},
{kinds: roomMemberKinds},
{kinds: MESSAGE_KINDS, since},
makeCommentFilter(CONTENT_KINDS, {since}),
],
onEvent: event => {
if (event.kind === ROOM_META) {
ifLet(getTagValue("d", event.tags), pullRoomContent)
}
},
})
listen({
url,
signal: controller.signal,
filters: [{kinds: REACTION_KINDS}],
filters: [{kinds: [LIVEKIT_PARTICIPANTS]}],
})
return () => controller.abort()
-107
View File
@@ -1,107 +0,0 @@
import {derived, get} from "svelte/store"
import {not, ifLet, sample} from "@welshman/lib"
import {getRelaysFromList, RelayMode} from "@welshman/util"
import {
getRelay,
setWriteRelays,
setReadRelays,
setSearchRelays,
setMessagingRelays,
userRelayList,
userSearchRelayList,
userMessagingRelayList,
} from "@welshman/app"
import {hasNip50, DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/core/state"
export type HealthCheckContext = {
readRelays: string[]
writeRelays: string[]
messagingRelays: string[]
searchRelays: string[]
}
export type HealthCheck = {
title: string
description: string
action: string
isPending: (context: HealthCheckContext) => boolean
apply: (context: HealthCheckContext) => unknown
}
export const healthCheckContext = derived(
[userRelayList, userSearchRelayList, userMessagingRelayList],
([$userRelayList, $userSearchRelayList, $userMessagingRelayList]) => {
return {
readRelays: getRelaysFromList($userRelayList, RelayMode.Read),
writeRelays: getRelaysFromList($userRelayList, RelayMode.Write),
searchRelays: getRelaysFromList($userSearchRelayList),
messagingRelays: getRelaysFromList($userMessagingRelayList),
}
},
)
const healthChecks: HealthCheck[] = [
{
title: "Missing Inbox Relays",
description: "Other people aren't currently able to reliably tag you in public notes.",
action: "Update",
isPending: context => context.readRelays.length <= 1,
apply: () => setReadRelays(DEFAULT_RELAYS),
},
{
title: "Missing Outbox Relays",
description: "Other people aren't currently able to reliably find your public notes.",
action: "Update",
isPending: context => context.writeRelays.length <= 1,
apply: () => setWriteRelays(DEFAULT_RELAYS),
},
{
title: "Missing DM Relays",
description: "You aren't currently able to reliably send or receive direct messages.",
action: "Update",
isPending: context => context.messagingRelays.length <= 1,
apply: () => setMessagingRelays(DEFAULT_MESSAGING_RELAYS),
},
{
title: "Too Many Inbox Relays",
description:
"You have more inbox relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
isPending: context => context.readRelays.length > 8,
apply: context => setReadRelays(sample(5, context.readRelays)),
},
{
title: "Too Many Outbox Relays",
description:
"You have more outbox relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
isPending: context => context.writeRelays.length > 8,
apply: context => setWriteRelays(sample(5, context.writeRelays)),
},
{
title: "Too Many DM Relays",
description:
"You have more DM relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
isPending: context => context.messagingRelays.length > 8,
apply: context => setMessagingRelays(sample(5, context.messagingRelays)),
},
{
title: "Invalid Search Relays",
description: "Some of your search relays don't support search.",
action: "Remove Invalid",
isPending: context => context.searchRelays.some(url => not(ifLet(getRelay(url), hasNip50))),
apply: context =>
setSearchRelays(context.searchRelays.filter(url => ifLet(getRelay(url), hasNip50))),
},
]
export const isHealthCheckPending = (healthCheck: HealthCheck) =>
healthCheck.isPending(get(healthCheckContext))
export const applyHealthCheck = (healthCheck: HealthCheck) =>
healthCheck.apply(get(healthCheckContext))
export const pendingHealthChecks = derived(healthCheckContext, ctx =>
healthChecks.filter(hc => hc.isPending(ctx)),
)
+1 -2
View File
@@ -1,4 +1,4 @@
import {db, kv, ss} from "@app/core/storage"
import {kv, db} from "@app/core/storage"
import {Push} from "@app/util/notifications"
import {deactivateCurrentPomadeSession} from "@app/util/pomade"
@@ -6,7 +6,6 @@ export const logout = async () => {
await deactivateCurrentPomadeSession()
await Push.disable()
await kv.clear()
await ss.clear()
await db.clear()
localStorage.clear()
+520 -30
View File
@@ -1,24 +1,87 @@
import {derived} from "svelte/store"
import type {Unsubscriber, Readable, Subscriber} from "svelte/store"
import {derived, get} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import {Badge} from "@capawesome/capacitor-badge"
import {PushNotifications} from "@capacitor/push-notifications"
import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications"
import {synced, throttled, withGetter} from "@welshman/store"
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 {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/util/routes"
import {load, LOCAL_RELAY_URL} from "@welshman/net"
import {
pubkey,
tracker,
repository,
publishThunk,
loadRelay,
relaysByUrl,
waitForThunkError,
userMessagingRelayList,
} from "@welshman/app"
import {
on,
call,
find,
assoc,
poll,
prop,
hash,
spec,
first,
identity,
now,
maybe,
throttle,
} from "@welshman/lib"
import type {TrustedEvent, Filter} from "@welshman/util"
import {deriveEventsByIdByUrl} from "@welshman/store"
import {
DELETE,
getTagValue,
getPubkeyTagValues,
getRelaysFromList,
matchFilter,
matchFilters,
getIdFilters,
sortEventsDesc,
makeEvent,
Address,
} from "@welshman/util"
import {buildUrl} from "@lib/util"
import {
makeSpacePath,
makeRoomPath,
makeSpaceChatPath,
makeChatPath,
getEventPath,
goToEvent,
} from "@app/util/routes"
import {
DM_KINDS,
CONTENT_KINDS,
MESSAGE_KINDS,
PUSH_BRIDGE,
PUSH_SERVER,
notificationSettings,
notificationState,
chatsById,
userSettingsValues,
userGroupList,
getSpaceUrlsFromGroupList,
getSpaceRoomsFromGroupList,
makeCommentFilter,
userSpaceUrls,
shouldNotify,
hasNip29,
device,
} from "@app/core/state"
import {kv} from "@app/core/storage"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
export {Push} from "@app/util/push"
// Temporarily copied from welshman
type Stores = Readable<any> | [Readable<any>, ...Array<Readable<any>>] | Array<Readable<any>>
const merged = <S extends Stores>(stores: S) => derived(stores, identity)
// Checked state
@@ -45,9 +108,13 @@ export const syncChecked = () => {
.map((_, i, segments) => segments.slice(0, i + 1).join("/"))
.slice(1)
// Set checked when we enter and when we leave a given page
return page.subscribe($page => {
// Set checked when we leave a given page
checked.update($checked => {
for (const path of getPaths($page.url.pathname)) {
$checked[path] = now()
}
for (const path of getPaths(prev)) {
$checked[path] = now()
}
@@ -55,17 +122,6 @@ export const syncChecked = () => {
return $checked
})
// Set checked when we visit a given page - but delay it a tad
setTimeout(() => {
checked.update($checked => {
for (const path of getPaths($page.url.pathname)) {
$checked[path] = now()
}
return $checked
})
}, 300)
prev = $page.url.pathname
})
}
@@ -74,7 +130,7 @@ export const syncChecked = () => {
export const allNotifications = derived(
throttled(
1000,
2000,
derived(
[
pubkey,
@@ -124,23 +180,27 @@ export const allNotifications = derived(
for (const url of getSpaceUrlsFromGroupList($userGroupList)) {
const spacePath = makeSpacePath(url)
const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())
const eventsById = eventsByIdByUrl.get(url) || new Map()
const latestEvent = first(sortEventsDesc(eventsById.values()))
if (hasNotification(spacePath, latestEvent)) {
paths.add(spacePath)
}
if (hasNip29($relaysByUrl.get(url))) {
for (const [h, [latestEvent]] of groupBy(e => getTagValue("h", e.tags), events)) {
if (h) {
const roomPath = makeRoomPath(url, h)
for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
const roomPath = makeRoomPath(url, h)
const latestEvent = find(e => e.tags.some(spec(["h", h])), eventsById.values())
if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePath)
paths.add(roomPath)
}
if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePath)
paths.add(roomPath)
}
}
} else {
const messagesPath = makeSpaceChatPath(url)
if (hasNotification(messagesPath, first(events))) {
if (hasNotification(messagesPath, first(eventsById.values()))) {
paths.add(spacePath)
paths.add(messagesPath)
}
@@ -155,6 +215,51 @@ export const notifications = derived([page, allNotifications], ([$page, $allNoti
return new Set([...$allNotifications].filter(p => !$page.url.pathname.startsWith(p)))
})
export const onNotification = call(() => {
const allFilters = [{kinds: [...MESSAGE_KINDS, ...DM_KINDS]}, makeCommentFilter(MESSAGE_KINDS)]
const filters = allFilters.map(assoc("since", now()))
const subscribers: Subscriber<TrustedEvent>[] = []
let unsubscribe: Unsubscriber | undefined
return (f: (event: TrustedEvent) => void) => {
subscribers.push(f)
if (!unsubscribe) {
unsubscribe = on(repository, "update", ({added}) => {
const $pubkey = pubkey.get()
for (const event of added) {
if (event.pubkey == $pubkey) {
continue
}
const h = getTagValue("h", event.tags)
if (Array.from(tracker.getRelays(event.id)).every(url => !shouldNotify(url, h))) {
continue
}
if (matchFilters(filters, event)) {
for (const f of subscribers) {
f(event)
}
}
}
})
}
return () => {
subscribers.splice(subscribers.indexOf(f), 1)
if (subscribers.length === 0) {
unsubscribe?.()
unsubscribe = undefined
}
}
}
})
// Badges
export const syncBadges = () =>
@@ -179,3 +284,388 @@ export const clearBadges = async () => {
// pass - firefox doesn't support this
}
}
// Push notifications
interface IPushAdapter {
request: (prompt?: boolean) => Promise<string>
disable: () => Promise<void>
enable: () => Promise<void>
}
class CapacitorNotifications implements IPushAdapter {
_controller = maybe<AbortController>()
async request(prompt = true) {
let status = await PushNotifications.checkPermissions()
if (prompt && ["prompt", "prompt-with-rationale"].includes(status.receive)) {
status = await PushNotifications.requestPermissions()
}
if (status.receive !== "granted") {
return status.receive
}
let {token} = notificationState.get()
let error = "failed to retrieve token"
if (!token) {
const listeners = [
PushNotifications.addListener("registration", ({value}: Token) => {
token = value
}),
PushNotifications.addListener("registrationError", (err: RegistrationError) => {
error = err.error
}),
]
await Promise.all([
PushNotifications.register(),
poll({
condition: () => Boolean(token),
signal: AbortSignal.timeout(5000),
}),
])
listeners.forEach(p => p.then(listener => listener.remove()))
notificationState.update(assoc("token", token))
}
return token ? status.receive : error
}
async _syncServer(signal: AbortSignal) {
const {token, subscription} = notificationState.get()
if (!token) {
throw new Error("Attempted to sync push server without a token")
}
if (!subscription) {
try {
const channel = Capacitor.getPlatform() === "ios" ? "apns" : "fcm"
const url = buildUrl(PUSH_SERVER, "subscription", channel)
const res = await fetch(url, {
signal,
method: "POST",
body: JSON.stringify({token}),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
})
if (!res.ok) {
console.warn(`Failed to register with push server (status ${res.status})`)
} else {
const json = await res.json()
if (json?.callback && json?.key) {
notificationState.update(assoc("subscription", json))
} else {
console.warn("Failed to register with push server (bad response)")
}
}
} catch (e) {
console.warn("Failed to register with push server:", e)
}
}
}
_getSubscriptionIdentifier = (relay: string, key: string) =>
String(hash(relay + key + device.get()))
_getPushUrl = async (url: string) => {
for (const candidate of [url, PUSH_BRIDGE]) {
const relay = await loadRelay(candidate)
if (relay?.supported_nips?.map(String)?.includes("9a")) {
return candidate
}
}
}
_syncRelay = async (relay: string, key: string, filters: Filter[], ignore: Filter[] = []) => {
const {subscription} = notificationState.get()
if (!subscription) {
console.warn(`Failed to subscribe ${relay} to notifications: no subscription`)
return
}
const url = await this._getPushUrl(relay)
if (!url) {
console.warn(`Failed to subscribe ${relay} to notifications: unsupported`)
return
}
const identifier = this._getSubscriptionIdentifier(relay, key)
const thunk = publishThunk({
relays: [url],
event: makeEvent(30390, {
tags: [
["d", identifier],
["relay", relay],
["callback", subscription.callback],
...ignore.map(filter => ["ignore", JSON.stringify(filter)]),
...filters.map(filter => ["filter", JSON.stringify(filter)]),
],
}),
})
const error = await waitForThunkError(thunk)
if (error) {
console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error)
}
}
_unsyncRelay = async (relay: string, key: string) => {
const url = await this._getPushUrl(relay)
if (!url) {
console.warn(`Failed to unsubscribe ${relay} from notifications: unsupported`)
return
}
const relays = [url]
const identifier = this._getSubscriptionIdentifier(relay, key)
const address = new Address(30390, pubkey.get()!, identifier).toString()
const event = makeEvent(DELETE, {tags: [["a", address]]})
const error = await waitForThunkError(publishThunk({relays, event}))
if (error) {
console.warn(`Failed to unsubscribe ${relay} from notifications:`, error)
}
}
async _syncSpaceSubscription(signal: AbortSignal) {
signal.addEventListener(
"abort",
merged([userSpaceUrls, notificationSettings, userSettingsValues]).subscribe(
throttle(3000, ([$userSpaceUrls, {spaces, mentions}, {alerts}]) => {
const baseFilters = [{kinds: MESSAGE_KINDS}, makeCommentFilter(CONTENT_KINDS)]
for (const url of $userSpaceUrls) {
const {notify = true, exceptions = []} = alerts.find(spec({url})) || {}
const filters: Filter[] = []
const ignore: Filter[] = []
// Build filters based on spaces setting
if (spaces) {
if (notify) {
// notify=true: exceptions are opt-out (exclude those rooms)
if (exceptions.length > 0) {
ignore.push({"#h": exceptions})
}
// Include all other content
filters.push(...baseFilters)
} else {
// notify=false: exceptions are opt-in (only include those rooms)
if (exceptions.length > 0) {
filters.push(...baseFilters.map(f => ({...f, "#h": exceptions})))
}
}
}
// Build filters for mentions - always notify for p-tagged content
if (mentions) {
filters.push(...baseFilters.map(f => ({...f, "#p": [pubkey.get()!]})))
}
// Sync or unsync based on whether we have filters
if (filters.length > 0) {
this._syncRelay(url, "spaces", filters, ignore)
} else {
this._unsyncRelay(url, "spaces")
}
}
}),
),
)
}
async _syncMessageSubscription(signal: AbortSignal) {
signal.addEventListener(
"abort",
merged([userMessagingRelayList, notificationSettings]).subscribe(
throttle(3000, ([$userMessagingRelayList, {messages}]) => {
for (const url of getRelaysFromList($userMessagingRelayList)) {
if (messages) {
this._syncRelay(url, "messages", [{kinds: DM_KINDS, "#p": [pubkey.get()!]}])
} else {
this._unsyncRelay(url, "messages")
}
}
}),
),
)
}
async enable() {
if (!this._controller) {
this._controller = new AbortController()
PushNotifications.addListener(
"pushNotificationActionPerformed",
async (action: ActionPerformed) => {
const {relay, id} = action.notification.data
const [event] = await load({
relays: [relay, LOCAL_RELAY_URL],
filters: getIdFilters([id]),
})
if (event) {
goto(await getEventPath(event, [relay]))
} else {
goto(makeSpacePath(relay))
}
},
)
this._controller.signal.addEventListener("abort", () => {
PushNotifications.removeAllListeners()
})
try {
await this._syncServer(this._controller.signal)
await this._syncSpaceSubscription(this._controller.signal)
await this._syncMessageSubscription(this._controller.signal)
} catch (e) {
console.error(e)
}
}
}
async disable() {
this._controller?.abort()
this._controller = undefined
const {subscription} = notificationState.get()
if (subscription) {
const res = await fetch(buildUrl(PUSH_SERVER, "subscription", subscription.key), {
method: "delete",
})
if (!res.ok) {
console.warn("Failed to delete push subscription")
}
}
notificationState.set({})
await Promise.all(get(userSpaceUrls).map(url => this._unsyncRelay(url, "spaces")))
await Promise.all(
getRelaysFromList(get(userMessagingRelayList)).map(url => this._unsyncRelay(url, "messages")),
)
}
}
class WebNotifications implements IPushAdapter {
_unsubscriber = maybe<Unsubscriber>()
async request(prompt = true) {
if (prompt && Notification?.permission === "default") {
await Notification.requestPermission()
}
return Notification?.permission || "denied"
}
_notify(event: TrustedEvent, title: string, body: string) {
console.log("notify:", event)
const notification = new Notification(title, {
body,
tag: event.id,
icon: "/icon.png",
badge: "/icon.png",
})
notification.onclick = () => {
window.focus()
goToEvent(event)
notification.close()
}
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
notification.close()
document.removeEventListener("visibilitychange", onVisibilityChange)
}
}
document.addEventListener("visibilitychange", onVisibilityChange)
}
async enable() {
if (!this._unsubscriber) {
this._unsubscriber = onNotification(event => {
const {push, messages, mentions, spaces} = notificationSettings.get()
if (push && document.hidden && Notification?.permission === "granted") {
if (messages && matchFilter({kinds: DM_KINDS}, event)) {
this._notify(event, "New direct message", "Someone sent you a direct message.")
} else if (
mentions &&
event.pubkey !== pubkey.get() &&
getPubkeyTagValues(event.tags).includes(pubkey.get()!)
) {
this._notify(event, "Someone mentioned you", "Someone tagged you in a message.")
} else if (spaces) {
this._notify(event, "New activity", "Someone posted a new message.")
}
}
})
}
}
async disable() {
this._unsubscriber?.()
this._unsubscriber = undefined
}
}
export class Push {
static _adapter: IPushAdapter | undefined
static _getAdapter() {
if (!Push._adapter) {
if (Capacitor.isNativePlatform()) {
Push._adapter = new CapacitorNotifications()
} else {
Push._adapter = new WebNotifications()
}
}
return Push._adapter
}
static request() {
return Push._getAdapter().request()
}
static disable() {
return Push._getAdapter().disable()
}
static enable() {
return Push._getAdapter().enable()
}
static sync() {
return notificationSettings.subscribe(({push}) => {
if (push) {
Push.enable()
} else {
Push.disable()
}
})
}
}
-92
View File
@@ -1,92 +0,0 @@
import {throttle} from "throttle-debounce"
import {App} from "@capacitor/app"
import {registerPlugin} from "@capacitor/core"
import {pubkey, getSession} from "@welshman/app"
import type {Session} from "@welshman/app"
import {maybe, now} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {pushState} from "@app/core/state"
import type {IPushAdapter} from "@app/util/push/adapters/common"
import {requestPermissions, syncRelaySubscriptions} from "@app/util/push/adapters/common"
type AndroidFallbackSubscription = {
relay: string
key: string
filters: Array<Filter>
ignore: Array<Filter>
}
type AndroidPushFallbackState = {
session?: Session
activeSince?: number
subscriptions?: Array<AndroidFallbackSubscription>
}
type AndroidPushFallbackPlugin = {
syncState: (args: {state: AndroidPushFallbackState}) => Promise<void>
}
const AndroidPushFallback = registerPlugin<AndroidPushFallbackPlugin>("AndroidPushFallback")
export class AndroidFallbackNotifications implements IPushAdapter {
_controller = maybe<AbortController>()
_subscriptions = new Map<string, AndroidFallbackSubscription>()
_activeSince = now()
async request() {
return requestPermissions()
}
async enable() {
if (!this._controller) {
this._controller = new AbortController()
const doSync = throttle(1000, () => {
AndroidPushFallback.syncState({
state: {
session: pubkey.get() ? getSession(pubkey.get()!) : undefined,
activeSince: this._activeSince,
subscriptions: Array.from(this._subscriptions.values()),
},
})
})
let appStateListener: Awaited<ReturnType<typeof App.addListener>> | undefined
App.addListener("appStateChange", ({isActive}) => {
if (!isActive) {
this._activeSince = now()
doSync()
}
}).then(handle => {
appStateListener = handle
})
this._controller.signal.addEventListener("abort", () => {
appStateListener?.remove()
})
syncRelaySubscriptions(this._controller.signal, async (relay, key, filters, ignore) => {
if (filters.length > 0) {
this._subscriptions.set(`${relay}:${key}`, {relay, key, filters, ignore})
} else {
this._subscriptions.delete(`${relay}:${key}`)
}
doSync()
})
pushState.set({useFallback: true})
}
}
async disable() {
this._controller?.abort()
this._controller = undefined
this._subscriptions.clear()
await AndroidPushFallback.syncState({state: {}})
pushState.set({})
}
}
-198
View File
@@ -1,198 +0,0 @@
import {get} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import {PushNotifications} from "@capacitor/push-notifications"
import {
pubkey,
publishThunk,
loadRelay,
waitForThunkError,
userMessagingRelayList,
} from "@welshman/app"
import {assoc, hash, maybe} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {DELETE, getRelaysFromList, makeEvent, Address} from "@welshman/util"
import {buildUrl} from "@lib/util"
import {PUSH_BRIDGE, PUSH_SERVER, pushState, userSpaceUrls, device} from "@app/core/state"
import type {IPushAdapter} from "@app/util/push/adapters/common"
import {
onPushNotificationAction,
syncRelaySubscriptions,
requestPermissions,
requestToken,
} from "@app/util/push/adapters/common"
export class CapacitorNotifications implements IPushAdapter {
_controller = maybe<AbortController>()
async request() {
const status = await requestPermissions()
if (status !== "granted") {
return status
}
const {token, error = "denied"} = await requestToken()
pushState.update(assoc("token", token))
return token ? "granted" : error
}
async _syncServer(signal: AbortSignal) {
const {token, subscription} = pushState.get()
if (!token) {
throw new Error("Attempted to sync push server without a token")
}
if (!subscription) {
try {
const channel = Capacitor.getPlatform() === "ios" ? "apns" : "fcm"
const url = buildUrl(PUSH_SERVER, "subscription", channel)
const res = await fetch(url, {
signal,
method: "POST",
body: JSON.stringify({token}),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
})
if (!res.ok) {
console.warn(`Failed to register with push server (status ${res.status})`)
} else {
const json = await res.json()
if (json?.callback && json?.key) {
pushState.update(assoc("subscription", json))
} else {
console.warn("Failed to register with push server (bad response)")
}
}
} catch (e) {
console.warn("Failed to register with push server:", e)
}
}
}
_getSubscriptionIdentifier = (relay: string, key: string) =>
String(hash(relay + key + device.get()))
_getPushUrl = async (url: string) => {
for (const candidate of [url, PUSH_BRIDGE]) {
const relay = await loadRelay(candidate)
if (relay?.supported_nips?.map(String)?.includes("9a")) {
return candidate
}
}
}
_syncRelay = async (relay: string, key: string, filters: Filter[], ignore: Filter[] = []) => {
const {subscription} = pushState.get()
if (!subscription) {
console.warn(`Failed to subscribe ${relay} to notifications: no subscription`)
return
}
const url = await this._getPushUrl(relay)
if (!url) {
console.warn(`Failed to subscribe ${relay} to notifications: unsupported`)
return
}
const identifier = this._getSubscriptionIdentifier(relay, key)
const thunk = publishThunk({
relays: [url],
event: makeEvent(30390, {
tags: [
["d", identifier],
["relay", relay],
["callback", subscription.callback],
...ignore.map(filter => ["ignore", JSON.stringify(filter)]),
...filters.map(filter => ["filter", JSON.stringify(filter)]),
],
}),
})
const error = await waitForThunkError(thunk)
if (error) {
console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error)
}
}
_unsyncRelay = async (relay: string, key: string) => {
const url = await this._getPushUrl(relay)
if (!url) {
console.warn(`Failed to unsubscribe ${relay} from notifications: unsupported`)
return
}
const relays = [url]
const identifier = this._getSubscriptionIdentifier(relay, key)
const address = new Address(30390, pubkey.get()!, identifier).toString()
const event = makeEvent(DELETE, {tags: [["a", address]]})
const error = await waitForThunkError(publishThunk({relays, event}))
if (error) {
console.warn(`Failed to unsubscribe ${relay} from notifications:`, error)
}
}
async enable() {
if (!this._controller) {
this._controller = new AbortController()
PushNotifications.addListener("pushNotificationActionPerformed", onPushNotificationAction)
this._controller.signal.addEventListener("abort", () => {
PushNotifications.removeAllListeners()
})
try {
await this._syncServer(this._controller.signal)
syncRelaySubscriptions(this._controller.signal, (url, key, filters, ignore) => {
if (filters.length > 0) {
this._syncRelay(url, key, filters, ignore)
} else {
this._unsyncRelay(url, key)
}
})
} catch (e) {
console.error(e)
}
}
}
async disable() {
this._controller?.abort()
this._controller = undefined
const {subscription} = pushState.get()
if (subscription) {
const res = await fetch(buildUrl(PUSH_SERVER, "subscription", subscription.key), {
method: "delete",
})
if (!res.ok) {
console.warn("Failed to delete push subscription")
}
}
pushState.set({})
await Promise.all(get(userSpaceUrls).map(url => this._unsyncRelay(url, "spaces")))
await Promise.all(
getRelaysFromList(get(userMessagingRelayList)).map(url => this._unsyncRelay(url, "messages")),
)
}
}
-208
View File
@@ -1,208 +0,0 @@
import {goto} from "$app/navigation"
import type {Subscriber, Unsubscriber} from "svelte/store"
import {
PushNotifications,
type ActionPerformed,
type RegistrationError,
type Token,
} from "@capacitor/push-notifications"
import type {PluginListenerHandle} from "@capacitor/core"
import {pubkey, repository, tracker, userMessagingRelayList} from "@welshman/app"
import {merged} from "@welshman/store"
import {assoc, call, now, on, poll, spec, throttle} from "@welshman/lib"
import {load, LOCAL_RELAY_URL} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net"
import {
getIdFilters,
getRelaysFromList,
getTagValue,
matchFilters,
type Filter,
type TrustedEvent,
} from "@welshman/util"
import {
DM_KINDS,
CONTENT_KINDS,
MESSAGE_KINDS,
notificationSettings,
pushState,
shouldNotify,
userSpaceUrls,
userSettingsValues,
makeCommentFilter,
} from "@app/core/state"
import {makeSpacePath, getEventPath} from "@app/util/routes"
export interface IPushAdapter {
request: (prompt?: boolean) => Promise<string>
disable: () => Promise<void>
enable: () => Promise<void>
}
export type PushPermissionResult = {
token?: string
error?: string
}
export const onNotification = call(() => {
const allFilters = [{kinds: [...MESSAGE_KINDS, ...DM_KINDS]}, makeCommentFilter(MESSAGE_KINDS)]
const filters = allFilters.map(assoc("since", now()))
const subscribers: Subscriber<TrustedEvent>[] = []
let unsubscribe: Unsubscriber | undefined
return (f: (event: TrustedEvent) => void) => {
subscribers.push(f)
if (!unsubscribe) {
unsubscribe = on(repository, "update", ({added}: RepositoryUpdate) => {
const $pubkey = pubkey.get()
for (const event of added) {
if (event.pubkey == $pubkey) {
continue
}
const h = getTagValue("h", event.tags)
if (Array.from(tracker.getRelays(event.id)).every(url => !shouldNotify(url, h))) {
continue
}
if (matchFilters(filters, event)) {
for (const f of subscribers) {
f(event)
}
}
}
})
}
return () => {
subscribers.splice(subscribers.indexOf(f), 1)
if (subscribers.length === 0) {
unsubscribe?.()
unsubscribe = undefined
}
}
}
})
export const onPushNotificationAction = async (action: ActionPerformed) => {
const {relay, id} = action.notification.data
const [event] = await load({
relays: [relay, LOCAL_RELAY_URL],
filters: getIdFilters([id]),
})
if (event) {
goto(await getEventPath(event, [relay]))
} else {
goto(makeSpacePath(relay))
}
}
export const requestPermissions = async (): Promise<string> => {
let status = await PushNotifications.checkPermissions()
if (["prompt", "prompt-with-rationale"].includes(status.receive)) {
status = await PushNotifications.requestPermissions()
}
return status.receive
}
export const requestToken = async (): Promise<PushPermissionResult> => {
let {token} = pushState.get()
let error = "failed to retrieve token"
if (!token) {
const listeners = [
PushNotifications.addListener("registration", ({value}: Token) => {
token = value
}),
PushNotifications.addListener("registrationError", (err: RegistrationError) => {
error = err.error
}),
]
await Promise.all([
PushNotifications.register(),
poll({
condition: () => Boolean(token),
signal: AbortSignal.timeout(5000),
}),
])
listeners.forEach(p => p.then((listener: PluginListenerHandle) => listener.remove()))
}
return token ? {token} : {error}
}
export const syncRelaySubscriptions = (
signal: AbortSignal,
sync: (url: string, key: string, filters: Filter[], ignore: Filter[]) => void,
) => {
const $pubkey = pubkey.get()
if (!$pubkey) {
throw new Error("Attempted to sync push subscriptions without an active pubkey")
}
const unsubscribeSpaces = merged([
userSpaceUrls,
notificationSettings,
userSettingsValues,
]).subscribe(
throttle(3000, ([$userSpaceUrls, {spaces, mentions}, {alerts}]) => {
const baseFilters = [{kinds: MESSAGE_KINDS}, makeCommentFilter(CONTENT_KINDS)]
for (const url of $userSpaceUrls) {
const {notify = true, exceptions = []} = alerts.find(spec({url})) || {}
const filters: Filter[] = []
const ignore: Filter[] = []
if (spaces) {
if (notify) {
if (exceptions.length > 0) {
ignore.push({"#h": exceptions})
}
filters.push(...baseFilters)
} else {
if (exceptions.length > 0) {
filters.push(...baseFilters.map(f => ({...f, "#h": exceptions})))
}
}
}
if (mentions) {
filters.push(...baseFilters.map(f => ({...f, "#p": [$pubkey]})))
}
sync(url, "spaces", filters, ignore)
}
}),
)
const unsubscribeMessages = merged([userMessagingRelayList, notificationSettings]).subscribe(
throttle(3000, ([$userMessagingRelayList, {messages}]) => {
for (const url of getRelaysFromList($userMessagingRelayList)) {
const filters: Filter[] = []
if (messages) {
filters.push({kinds: DM_KINDS, "#p": [$pubkey]})
}
sync(url, "messages", filters, [])
}
}),
)
signal.addEventListener("abort", () => {
unsubscribeSpaces()
unsubscribeMessages()
})
}
-73
View File
@@ -1,73 +0,0 @@
import {pubkey} from "@welshman/app"
import {maybe} from "@welshman/lib"
import type {Unsubscriber} from "svelte/store"
import {getPubkeyTagValues, matchFilter, type TrustedEvent} from "@welshman/util"
import {DM_KINDS, notificationSettings} from "@app/core/state"
import type {IPushAdapter} from "@app/util/push/adapters/common"
import {onNotification} from "@app/util/push/adapters/common"
import {goToEvent} from "@app/util/routes"
export class WebNotifications implements IPushAdapter {
_unsubscriber = maybe<Unsubscriber>()
async request(prompt = true) {
if (prompt && Notification?.permission === "default") {
await Notification.requestPermission()
}
return Notification?.permission || "denied"
}
_notify(event: TrustedEvent, title: string, body: string) {
console.log("notify:", event)
const notification = new Notification(title, {
body,
tag: event.id,
icon: "/icon.png",
badge: "/icon.png",
})
notification.onclick = () => {
window.focus()
goToEvent(event)
notification.close()
}
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
notification.close()
document.removeEventListener("visibilitychange", onVisibilityChange)
}
}
document.addEventListener("visibilitychange", onVisibilityChange)
}
async enable() {
if (!this._unsubscriber) {
this._unsubscriber = onNotification(event => {
const {push, messages, mentions, spaces} = notificationSettings.get()
if (push && document.hidden && Notification?.permission === "granted") {
if (messages && matchFilter({kinds: DM_KINDS}, event)) {
this._notify(event, "New direct message", "Someone sent you a direct message.")
} else if (
mentions &&
event.pubkey !== pubkey.get() &&
getPubkeyTagValues(event.tags).includes(pubkey.get()!)
) {
this._notify(event, "Someone mentioned you", "Someone tagged you in a message.")
} else if (spaces) {
this._notify(event, "New activity", "Someone posted a new message.")
}
}
})
}
}
async disable() {
this._unsubscriber?.()
this._unsubscriber = undefined
}
}
-62
View File
@@ -1,62 +0,0 @@
import {Capacitor} from "@capacitor/core"
import {notificationSettings, pushState} from "@app/core/state"
import {WebNotifications} from "@app/util/push/adapters/web"
import {CapacitorNotifications} from "@app/util/push/adapters/capacitor"
import {AndroidFallbackNotifications} from "@app/util/push/adapters/android"
import type {IPushAdapter} from "@app/util/push/adapters/common"
export {onNotification} from "@app/util/push/adapters/common"
export class Push {
static _adapter: IPushAdapter | undefined
static _getAdapter() {
if (!Push._adapter) {
const {useFallback} = pushState.get()
if (Capacitor.getPlatform() === "android" && useFallback) {
Push._adapter = new AndroidFallbackNotifications()
} else if (Capacitor.isNativePlatform()) {
Push._adapter = new CapacitorNotifications()
} else {
Push._adapter = new WebNotifications()
}
}
return Push._adapter
}
static async request() {
const adapter = Push._getAdapter()
let permission = await adapter.request()
if (permission !== "granted" && adapter instanceof CapacitorNotifications) {
Push._adapter = new AndroidFallbackNotifications()
permission = await Push._adapter.request()
if (permission === "granted") {
pushState.set({useFallback: true})
}
}
return permission
}
static disable() {
return Push._getAdapter().disable()
}
static enable() {
return Push._getAdapter().enable()
}
static sync() {
return notificationSettings.subscribe(({push}) => {
if (push) {
Push.enable()
} else {
Push.disable()
}
})
}
}
+40 -26
View File
@@ -1,3 +1,4 @@
import type {Page} from "@sveltejs/kit"
import theme from "tailwindcss/defaultTheme"
import {get} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
@@ -6,7 +7,7 @@ import {page} from "$app/stores"
import {nthEq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {tracker, userMessagingRelayList} from "@welshman/app"
import {tracker} from "@welshman/app"
import {identity} from "@welshman/lib"
import {
getTagValue,
@@ -16,32 +17,17 @@ import {
ZAP_GOAL,
EVENT_TIME,
getPubkeyTagValues,
getRelaysFromList,
} from "@welshman/util"
import {makeChatId, entityLink, encodeRelay, DM_KINDS, ROOM} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {
makeChatId,
entityLink,
decodeRelay,
encodeRelay,
userSpaceUrls,
DM_KINDS,
ROOM,
} from "@app/core/state"
import {lastPageBySpaceUrl, lastChatUrl} from "@app/util/history"
import ChatEnable from "@app/components/ChatEnable.svelte"
// Chat
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(url)}/${h}`
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
export const goToChat = (pubkeys: string[] = []) => {
if (getRelaysFromList(get(userMessagingRelayList)).length === 0) {
pushModal(ChatEnable, {next: () => goToChat(pubkeys)})
} else if (pubkeys.length === 0) {
goto(lastChatUrl ?? "/chat")
} else {
goto(makeChatPath(pubkeys))
}
}
// Spaces
export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) => {
let path = `/spaces/${encodeRelay(url)}`
@@ -70,7 +56,15 @@ export const goToSpace = async (url: string) => {
}
}
// Content types, events
export const goToLastChat = () => {
goto(lastChatUrl ?? "/chat")
}
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(url)}/${h}`
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
export const makeMessagePath = (url: string, event: TrustedEvent) => {
const h = getTagValue(ROOM, event.tags)
@@ -90,6 +84,26 @@ export const makeClassifiedPath = (url: string, address?: string) =>
export const makeCalendarPath = (url: string, address?: string) =>
makeSpacePath(url, "calendar", address)
export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1]
export const getPrimaryNavItemIndex = ($page: Page) => {
const urls = get(userSpaceUrls)
switch (getPrimaryNavItem($page)) {
case "discover":
return urls.length + 2
case "spaces": {
const routeUrl = decodeRelay($page.params.relay || "")
return urls.findIndex(url => url === routeUrl) + 1
}
case "settings":
return urls.length + 3
default:
return 0
}
}
export const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any
+188 -67
View File
@@ -4,11 +4,11 @@
*/
import {
DisconnectReason,
Room as LiveKitRoom,
LocalParticipant,
LocalTrackPublication,
Room,
RoomEvent,
Track,
type AudioCaptureOptions,
type LocalParticipant,
} from "livekit-client"
import {derived, get, writable} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib"
@@ -17,7 +17,7 @@ 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 {deriveLatestEventForUrl} from "@app/core/state"
import {pushToast} from "@app/util/toast"
export const LIVEKIT_PARTICIPANTS = 39004
@@ -27,28 +27,38 @@ export {checkRelayHasLivekit} from "$lib/livekit"
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
room: Room
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 type VoiceState = "joining" | "connected" | "disconnected"
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const voiceState = writable<VoiceState>("disconnected")
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
/** Bumps when remote video is subscribed/unsubscribed so layout/video UI can react. */
export const videoCallLayoutRevision = writable(0)
/** Spotlight tile id — must match VideoCallContent `tileKey` (identity + source, not trackSid). */
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
const bumpVideoCallLayoutRevision = () => videoCallLayoutRevision.update(n => n + 1)
const addParticipant = (identity: string) => {
participantPubkeyMap.update(m => {
const next = new Map(m)
@@ -121,7 +131,10 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
],
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
const inCall =
$participantPubkeyMap.size > 0 &&
$currentVoiceRoom?.url === url &&
$currentVoiceRoom?.h === h
if (inCall) {
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
@@ -131,7 +144,7 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
if (!latestEvent) return []
const participants = removeUndefined(
map(
(tag: string[]) => (tag[1] ? {pubkey: tag[1], identity: tag[1]} : undefined),
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
getTags("participant", latestEvent.tags),
),
)
@@ -140,41 +153,20 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
},
)
const setUpMicrophone = async (
startMuted: boolean,
preferredMicId: string | undefined,
participant: LocalParticipant,
): Promise<boolean> => {
if (startMuted) {
return true
}
let muted = true
let capture: AudioCaptureOptions | undefined = undefined
if (preferredMicId) {
capture = {deviceId: preferredMicId}
}
try {
await participant.setMicrophoneEnabled(true, capture)
muted = false
} catch (e) {
pushToast({theme: "error", message: "Could not access microphone"})
}
return muted
}
const onRoomDisconnected = (reason?: DisconnectReason) => {
videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined)
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
currentVoiceSession.set(undefined)
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
voiceState.set("disconnected")
const message =
reason === DisconnectReason.JOIN_FAILURE
? "Could not connect to voice room. Please try again."
: "Voice connection lost."
pushToast({theme: "error", message})
}
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
}
const onTrackSubscribed = (track: Track) => {
@@ -183,11 +175,16 @@ const onTrackSubscribed = (track: Track) => {
element.style.display = "none"
document.body.appendChild(element)
element.play().catch(() => {})
} else if (track.kind === Track.Kind.Video) {
bumpVideoCallLayoutRevision()
}
}
const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
bumpVideoCallLayoutRevision()
}
}
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
@@ -208,25 +205,32 @@ 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})
bumpVideoCallLayoutRevision()
}
let joinAbortController: AbortController | undefined
export const cancelJoinVoiceRoom = () => {
joinAbortController?.abort()
}
export const joinVoiceRoom = async (
url: string,
h: string,
startMuted = true,
preferredMicId?: string,
): Promise<void> => {
export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
cancelJoinVoiceRoom()
const session = get(currentVoiceSession)
if (session) await leaveVoiceRoom()
currentVoiceRoom.set(get(deriveRoom(url, h)))
voiceState.set(VoiceState.Joining)
currentVoiceRoom.set({url, h})
voiceState.set("joining")
const controller = new AbortController()
joinAbortController = controller
@@ -238,41 +242,48 @@ export const joinVoiceRoom = async (
if (signal.aborted) throw new AbortError()
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
const room = new Room({adaptiveStream: true, dynacast: true})
liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
room.on(RoomEvent.Disconnected, onRoomDisconnected)
room.on(RoomEvent.ParticipantConnected, onParticipantConnected)
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
try {
await Promise.race([
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
room.connect(server_url, participant_token, {maxRetries: 0}),
whenTimeout(5_000, {
message: "Connection timed out. Please check your network and try again.",
}),
whenAborted(signal),
])
} catch (e) {
liveKitRoom.disconnect()
room.disconnect()
throw e
}
participantPubkeyMap.set(new Map())
addParticipant(liveKitRoom.localParticipant.identity)
for (const p of liveKitRoom.remoteParticipants.values()) {
addParticipant(room.localParticipant.identity)
for (const p of room.remoteParticipants.values()) {
addParticipant(p.identity)
}
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
let muted = false
try {
await room.localParticipant.setMicrophoneEnabled(true)
} catch (e) {
muted = true
pushToast({theme: "error", message: "Could not access microphone"})
}
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
voiceState.set(VoiceState.Connected)
currentVoiceSession.set({url, h, room, muted, cameraOn: false, screenShareOn: false})
voiceState.set("connected")
playJoinSound()
} catch (e) {
if (isActive()) voiceState.set(VoiceState.Disconnected)
if (isActive()) voiceState.set("disconnected")
if (e instanceof AbortError) return
throw e
} finally {
@@ -287,17 +298,34 @@ export const leaveVoiceRoom = async () => {
const audio = new Audio("/leave-voice-room.mp3")
audio.play().catch(() => {})
voiceState.set(VoiceState.Disconnected)
if (session.cameraOn) {
try {
await session.room.localParticipant.setCameraEnabled(false)
} catch {
/* pass */
}
}
if (session.screenShareOn) {
try {
await session.room.localParticipant.setScreenShareEnabled(false)
} catch {
/* pass */
}
}
voiceState.set("disconnected")
videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
session.room.disconnect()
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
}
export const rejoinVoiceRoom = async (): Promise<void> => {
export const rejoinVoiceRoom = () => {
const target = get(currentVoiceRoom)
if (!target) return
return joinVoiceRoom(target.url, target.h)
if (target) joinVoiceRoom(target.url, target.h)
}
export const toggleMute = async () => {
@@ -319,3 +347,96 @@ export const toggleMute = async () => {
pushToast({theme: "error", message: "Could not access microphone"})
}
}
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const roomHasSubscribedRemoteVisual = (room: Room): boolean => {
for (const p of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = p.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) return true
}
}
return false
}
/** True when local camera/screen share is on or any subscribed remote camera/screen track. */
export const videoCallContentActive = derived(
[currentVoiceSession, voiceState, videoCallLayoutRevision],
([$session, $state, _rev]) => {
if ($state !== "connected" || !$session) return false
if ($session.cameraOn || $session.screenShareOn) return true
return roomHasSubscribedRemoteVisual($session.room)
},
)
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 videoTileCount = derived(
[currentVoiceSession, voiceState, videoCallLayoutRevision],
([$session, $state, _rev]) => {
if ($state !== "connected" || !$session) return 0
return countLiveVisualFeeds($session)
},
)
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
if (!cameraOn) {
session.room.localParticipant.setCameraEnabled(false)
currentVoiceSession.set({...session, cameraOn})
bumpVideoCallLayoutRevision()
return
}
try {
await session.room.localParticipant.setCameraEnabled(true)
currentVoiceSession.set({...session, cameraOn})
bumpVideoCallLayoutRevision()
} catch (e) {
pushToast({theme: "error", message: "Could not access camera"})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
if (!screenShareOn) {
session.room.localParticipant.setScreenShareEnabled(false)
currentVoiceSession.set({...session, screenShareOn})
bumpVideoCallLayoutRevision()
return
}
try {
await session.room.localParticipant.setScreenShareEnabled(true)
currentVoiceSession.set({...session, screenShareOn})
bumpVideoCallLayoutRevision()
} catch (e) {
pushToast({theme: "error", message: "Could not start screen sharing"})
}
}
+6 -9
View File
@@ -10,7 +10,6 @@
type Props = {
onClose?: any
noEscape?: boolean
fullscreen?: boolean
children: {
component: Component<any>
@@ -18,7 +17,7 @@
}
}
const {onClose = noop, noEscape = false, fullscreen = false, children}: Props = $props()
const {onClose = noop, fullscreen = false, children}: Props = $props()
const wrapperClass = $derived(
cx("absolute inset-0 flex sm:relative pointer-events-none", {
@@ -48,13 +47,11 @@
</button>
<div class={wrapperClass}>
<div class={innerClass} transition:fly>
{#if !noEscape}
<Button
class="absolute -top-4 right-3 btn btn-circle btn-neutral btn-sm"
onclick={clearModals}>
<Icon icon={Close} size={6} />
</Button>
{/if}
<Button
class="absolute -top-4 right-3 btn btn-circle btn-neutral btn-sm"
onclick={clearModals}>
<Icon icon={Close} size={6} />
</Button>
<children.component {...children.props} />
</div>
</div>
+9 -4
View File
@@ -5,14 +5,19 @@
interface Props {
element?: Element
children?: Snippet
/** Desktop voice: chat occupies the right half in split view. */
contentFrame?: "default" | "split-right"
[key: string]: any
}
let {children, element = $bindable(), ...props}: Props = $props()
let {children, element = $bindable(), contentFrame = "default", ...props}: Props = $props()
const className = cx(
props.class,
"scroll-container cw cb ct fixed z-feature overflow-y-auto overflow-x-hidden",
const className = $derived(
cx(
props.class,
"scroll-container cb ct fixed z-feature overflow-y-auto overflow-x-hidden",
contentFrame === "split-right" ? "cw-split-chat" : "cw",
),
)
</script>
+14 -14
View File
@@ -15,20 +15,7 @@
const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus"))
</script>
{#if onclick}
<Button {onclick} class="relative z-nav-item flex h-14 w-14 items-center justify-center p-1">
<div
class="aspect-square flex-grow cursor-pointer rounded-full {restProps.class} flex items-center justify-center transition-colors hover:bg-base-300"
class:bg-base-300={active}
class:tooltip={title}
data-tip={title}>
{@render children?.()}
{#if !active && notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</div>
</Button>
{:else}
{#if href}
<a {href} class="relative z-nav-item flex h-14 w-14 items-center justify-center p-1">
<div
class="aspect-square flex-grow cursor-pointer rounded-full {restProps.class} flex items-center justify-center transition-colors hover:bg-base-300"
@@ -41,4 +28,17 @@
{/if}
</div>
</a>
{:else}
<Button {onclick} class="relative z-nav-item flex h-14 w-14 items-center justify-center p-1">
<div
class="aspect-square flex-grow cursor-pointer rounded-full {restProps.class} flex items-center justify-center transition-colors hover:bg-base-300"
class:bg-base-300={active}
class:tooltip={title}
data-tip={title}>
{@render children?.()}
{#if !active && notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</div>
</Button>
{/if}
+1 -1
View File
@@ -12,7 +12,7 @@
<div
class={cx(
"ml-sai mt-sai mb-sai max-h-screen w-60 sm:flex-shrink-0 flex-col gap-1 bg-base-300 z-nav hidden md:flex",
"ml-sai mt-sai mb-sai max-h-screen w-60 flex-shrink-0 flex-col gap-1 bg-base-300 z-nav hidden md:flex",
props.class,
)}>
{@render children?.()}
+2 -4
View File
@@ -39,8 +39,7 @@
class:bg-base-100={active}>
{@render children?.()}
{#if notification}
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
</div>
<div class="absolute right-2 top-5 h-2 w-2 rounded-full bg-primary" transition:fade></div>
{/if}
</a>
{:else}
@@ -50,8 +49,7 @@
class:text-base-content={active}
class:bg-base-100={active}>
{#if notification}
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
</div>
<div class="absolute right-2 top-5 h-2 w-2 rounded-full bg-primary" transition:fade></div>
{/if}
{@render children?.()}
</button>
+11 -15
View File
@@ -27,8 +27,13 @@
import {setupHistory} from "@app/util/history"
import {setupAnalytics} from "@app/util/analytics"
import {authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy} from "@app/util/policies"
import {db, kv, ss} from "@app/core/storage"
import {device, userSettingsValues, notificationSettings, pushState} from "@app/core/state"
import {kv, db} from "@app/core/storage"
import {
device,
userSettingsValues,
notificationSettings,
notificationState,
} from "@app/core/state"
import {syncApplicationData} from "@app/core/sync"
import * as commands from "@app/core/commands"
import * as requests from "@app/core/requests"
@@ -36,7 +41,6 @@
import {theme} from "@app/util/theme"
import {toast, pushToast} from "@app/util/toast"
import * as notifications from "@app/util/notifications"
import {onPushNotificationAction} from "@app/util/push/adapters/common"
import * as storage from "@app/util/storage"
import {syncKeyboard} from "@app/util/keyboard"
import {getPageTitle} from "@app/util/title"
@@ -67,16 +71,8 @@
})
// Listen for deep link events
App.addListener("appUrlOpen", async (event: URLOpenListenerEvent) => {
App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => {
const url = new URL(event.url)
const relay = url.searchParams.get("relay")
const id = url.searchParams.get("id")
if (relay && id) {
onPushNotificationAction({notification: {data: {relay, id}}} as any)
return
}
const target = `${url.pathname}${url.search}${url.hash}`
goto(target, {replaceState: false, noScroll: false})
})
@@ -96,7 +92,7 @@
const unsubscribe = call(async () => {
const unsubscribers: Unsubscriber[] = []
// Sync stuff to storage
// Sync stuff to localstorage
await Promise.all([
sync({
key: "device",
@@ -111,7 +107,7 @@
sync({
key: "sessions",
store: sessions,
storage: ss,
storage: kv,
}),
sync({
key: "shouldUnwrap",
@@ -125,7 +121,7 @@
}),
sync({
key: "notificationState",
store: pushState,
store: notificationState,
storage: kv,
}),
])
+43 -26
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {Capacitor} from "@capacitor/core"
import {fly} from "@lib/transition"
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import Wallet from "@assets/icons/wallet.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
@@ -35,35 +36,51 @@
<SecondaryNavItem class="w-full !justify-between">
<strong class="ellipsize flex items-center gap-3"> Your Settings </strong>
</SecondaryNavItem>
<SecondaryNavItem href="/settings/profile">
<Icon icon={UserCircle} /> Profile
</SecondaryNavItem>
<SecondaryNavItem href="/settings/alerts">
<Icon icon={Bell} /> Alerts
</SecondaryNavItem>
{#if Capacitor.getPlatform() !== "ios"}
<div in:fly|local>
<SecondaryNavItem href="/settings/profile">
<Icon icon={UserCircle} /> Profile
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 50}}>
<SecondaryNavItem href="/settings/alerts">
<Icon icon={Bell} /> Alerts
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 100}} class:hidden={Capacitor.getPlatform() === "ios"}>
<SecondaryNavItem href="/settings/wallet">
<Icon icon={Wallet} /> Wallet
</SecondaryNavItem>
{/if}
<SecondaryNavItem href="/settings/relays">
<Icon icon={Server} /> Relays
</SecondaryNavItem>
<SecondaryNavItem href="/settings/content">
<Icon icon={GalleryMinimalistic} /> Content
</SecondaryNavItem>
<SecondaryNavItem href="/settings/privacy">
<Icon icon={Shield} /> Privacy
</SecondaryNavItem>
<SecondaryNavItem onclick={toggleTheme}>
<Icon icon={Moon} /> Theme
</SecondaryNavItem>
<SecondaryNavItem href="/settings/about">
<Icon icon={Code2} /> About
</SecondaryNavItem>
<SecondaryNavItem class="text-error hover:text-error" onclick={logout}>
<Icon icon={Exit} /> Log Out
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 150}}>
<SecondaryNavItem href="/settings/relays">
<Icon icon={Server} /> Relays
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 200}}>
<SecondaryNavItem href="/settings/content">
<Icon icon={GalleryMinimalistic} /> Content
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 250}}>
<SecondaryNavItem href="/settings/privacy">
<Icon icon={Shield} /> Privacy
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 300}}>
<SecondaryNavItem onclick={toggleTheme}>
<Icon icon={Moon} /> Theme
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 350}}>
<SecondaryNavItem href="/settings/about">
<Icon icon={Code2} /> About
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 400}}>
<SecondaryNavItem class="text-error hover:text-error" onclick={logout}>
<Icon icon={Exit} /> Log Out
</SecondaryNavItem>
</div>
</SecondaryNavSection>
</SecondaryNav>
+3 -5
View File
@@ -24,19 +24,17 @@
clearBadges()
}
let permission = "granted"
if (settings.push) {
permission = await Push.request()
const permissions = await Push.request()
if (!permission.startsWith("granted")) {
if (permissions !== "granted") {
await sleep(300)
settings.push = false
return pushToast({
theme: "error",
message: `Failed to request notification permissions (${permission}).`,
message: `Failed to request notification permissions (${permissions}).`,
})
}
}
+107 -9
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {derived} from "svelte/store"
import {shuffle, partition, ifLet} from "@welshman/lib"
import {
pubkey,
getRelayLists,
@@ -13,6 +15,10 @@
addSearchRelay,
removeSearchRelay,
getRelay,
setWriteRelays,
setReadRelays,
setSearchRelays,
setMessagingRelays,
} from "@welshman/app"
import {RelayMode} from "@welshman/util"
import Plane from "@assets/icons/plane.svg?dataurl"
@@ -20,11 +26,15 @@
import Server from "@assets/icons/server.svg?dataurl"
import Mailbox from "@assets/icons/mailbox.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import DangerTriangle from "@assets/icons/danger-triangle.svg?dataurl"
import ForbiddenCircle from "@assets/icons/forbidden-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import RelaySettingsItem from "@app/components/RelaySettingsItem.svelte"
import RelaySettingsHealthChecks from "@app/components/RelaySettingsHealthChecks.svelte"
import {hasNip50} from "@app/core/state"
import type {ActionItem} from "@app/components/RelaySettingsActionItem.svelte"
import RelaySettingsActionItems from "@app/components/RelaySettingsActionItems.svelte"
import {pushModal} from "@app/util/modal"
import {hasNip50, DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/core/state"
import {discoverRelays} from "@app/core/requests"
const readRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Read)
@@ -37,20 +47,108 @@
const removeReadRelay = (url: string) => removeRelay(url, RelayMode.Read)
const addWriteRelay = (url: string) => addRelay(url, RelayMode.Write)
const removeWriteRelay = (url: string) => removeRelay(url, RelayMode.Write)
const showActionItems = () => pushModal(RelaySettingsActionItems, {actionItems})
const actionItems = derived(
[readRelayUrls, writeRelayUrls, messagingRelayUrls, searchRelayUrls],
([$readRelayUrls, $writeRelayUrls, $messagingRelayUrls, $searchRelayUrls]) => {
const $actionItems: ActionItem[] = []
if ($readRelayUrls.length <= 1) {
$actionItems.push({
title: "Missing Inbox Relays",
subtitle: "Other people aren't currently able to reliably tag you in public notes.",
action: "Update",
apply: () => setReadRelays(DEFAULT_RELAYS),
})
}
if ($writeRelayUrls.length <= 1) {
$actionItems.push({
title: "Missing Outbox Relays",
subtitle: "Other people aren't currently able to reliably find your public notes.",
action: "Update",
apply: () => setWriteRelays(DEFAULT_RELAYS),
})
}
if ($messagingRelayUrls.length <= 1) {
$actionItems.push({
title: "Missing DM Relays",
subtitle: "You aren't currently able to reliably send or receive direct messages.",
action: "Update",
apply: () => setMessagingRelays(DEFAULT_MESSAGING_RELAYS),
})
}
if ($readRelayUrls.length > 8) {
$actionItems.push({
title: "Too Many Inbox Relays",
subtitle:
"You have more inbox relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
apply: () => setReadRelays(shuffle($readRelayUrls).slice(0, 5)),
})
}
if ($writeRelayUrls.length > 8) {
$actionItems.push({
title: "Too Many Outbox Relays",
subtitle:
"You have more outbox relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
apply: () => setWriteRelays(shuffle($writeRelayUrls).slice(0, 5)),
})
}
if ($messagingRelayUrls.length > 8) {
$actionItems.push({
title: "Too Many DM Relays",
subtitle:
"You have more DM relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
apply: () => setMessagingRelays(shuffle($messagingRelayUrls).slice(0, 5)),
})
}
const [okSearchRelays, badSearchRelays] = partition(
url => Boolean(ifLet(getRelay(url), hasNip50)),
$searchRelayUrls,
)
if (badSearchRelays.length > 0) {
$actionItems.push({
title: "Invalid Search Relays",
subtitle: `Some of your search relays don't support search.`,
action: "Remove Invalid",
apply: () => setSearchRelays(okSearchRelays),
})
}
return $actionItems
},
)
onMount(() => {
discoverRelays(getRelayLists())
})
</script>
<div class="content flex flex-col gap-4">
<RelaySettingsHealthChecks />
<div class="content">
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
<strong class="flex items-center gap-3 text-lg">
<Icon icon={Server} />
Your Relays
</strong>
<p class="mb-2">
<div class="flex justify-between">
<strong class="flex items-center gap-3 text-lg">
<Icon icon={Server} />
Your Relays
</strong>
{#if $actionItems.length > 0}
<Button class="btn btn-neutral btn-sm" onclick={showActionItems}>
<Icon icon={DangerTriangle} />
{$actionItems.length} Issue{$actionItems.length === 1 ? "" : "s"} Detected
</Button>
{/if}
</div>
<p class="text-sm mb-2">
Relays are servers which store your data, or allow you to find data from across the Nostr
network. We've set you up with some reasonable defaults, but if you're a power user, you can
customize your relay selections below.
+9 -2
View File
@@ -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}
+151 -40
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {onMount, tick} from "svelte"
import {readable} from "svelte/store"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
@@ -19,6 +19,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"
@@ -50,7 +51,8 @@
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 {currentVoiceRoom, videoTileCount, voiceState} from "@app/voice"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications"
@@ -63,6 +65,53 @@
const url = decodeRelay(relay)
const room = deriveRoom(url, h)
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
const voiceConnectedHere = $derived(
isVoiceRoom &&
$voiceState === "connected" &&
$currentVoiceRoom?.url === url &&
$currentVoiceRoom?.h === h,
)
let mobileRoomPanel = $state<"chat" | "video">("chat")
let voiceDesktopPanel = $state<"chat" | "video" | "split">("split")
const showMobileVideoPanel = $derived(
isVoiceRoom && $voiceState === "connected" && mobileRoomPanel === "video",
)
const pageContentFrame = $derived<"default" | "split-right">(
voiceConnectedHere && voiceDesktopPanel === "split" ? "split-right" : "default",
)
const pageContentHiddenDesktopVideoOnly = $derived(
voiceConnectedHere && voiceDesktopPanel === "video",
)
let prevVideoTileCount = $state(0)
$effect(() => {
if ($voiceState !== "connected") {
mobileRoomPanel = "chat"
voiceDesktopPanel = "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) {
voiceDesktopPanel = "video"
mobileRoomPanel = "video"
}
prevVideoTileCount = n
})
const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const at = $derived(parseInt($page.url.searchParams.get("at")!))
@@ -176,14 +225,13 @@
const newMessages = document.getElementById("new-messages")
if (newMessagesSeen) {
if (!newMessages || newMessagesSeen) {
showFixedNewMessages = false
} else if (newMessages) {
} else {
const {y} = newMessages.getBoundingClientRect()
if (y > 0 && y < 300) {
if (y > 300) {
newMessagesSeen = true
showFixedNewMessages = false
} else {
showFixedNewMessages = y < 0
}
@@ -301,7 +349,7 @@
elements.reverse()
requestAnimationFrame(manageScrollPosition)
tick().then(manageScrollPosition)
return elements
})
@@ -364,11 +412,47 @@
</script>
<SpaceBar>
{#snippet title()}
{#snippet icon()}
<RoomImage {url} {h} />
{/snippet}
{#snippet title()}
<RoomName {url} {h} />
{/snippet}
{#snippet action()}
{#if voiceConnectedHere}
<div class="flex gap-1 md:hidden">
<Button
class={cx("btn btn-sm", mobileRoomPanel === "chat" && "btn-primary")}
onclick={() => (mobileRoomPanel = "chat")}>
Chat
</Button>
<Button
class={cx("btn btn-sm", mobileRoomPanel === "video" && "btn-primary")}
onclick={() => (mobileRoomPanel = "video")}>
Video
</Button>
</div>
<div class="hidden flex-wrap gap-1 md:flex">
<Button
data-tip="Messages only"
class={cx("btn btn-sm", voiceDesktopPanel === "chat" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "chat")}>
Chat
</Button>
<Button
data-tip="Video only"
class={cx("btn btn-sm", voiceDesktopPanel === "video" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "video")}>
Video
</Button>
<Button
data-tip="Video and chat side by side"
class={cx("btn btn-sm", voiceDesktopPanel === "split" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "split")}>
Video + Chat
</Button>
</div>
{/if}
<SpaceSearch {url} {h} />
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
@@ -376,26 +460,37 @@
{/snippet}
</SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<PageContent
bind:element
onscroll={onScroll}
contentFrame={pageContentFrame}
class={cx(
showMobileVideoPanel
? "hidden flex-col-reverse pt-4 md:flex md:flex-col-reverse"
: "flex flex-col-reverse pt-4",
pageContentHiddenDesktopVideoOnly && "md:hidden",
)}>
<div bind:this={dynamicPadding}></div>
{#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 !$room.isClosed}
{#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}
{/if}
</div>
</div>
@@ -446,8 +541,22 @@
{/if}
</PageContent>
{#if voiceConnectedHere}
<VideoCallContent variant="desktop-split" {url} {h} visible={voiceDesktopPanel === "split"} />
<VideoCallContent variant="desktop-full" {url} {h} visible={voiceDesktopPanel === "video"} />
{/if}
{#if isVoiceRoom && $voiceState === "connected"}
<VideoCallContent variant="mobile" {url} {h} visible={mobileRoomPanel === "video"} />
{/if}
<div
class="chat__compose-zone flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0"
class={cx(
"chat__compose-zone flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0",
voiceConnectedHere && voiceDesktopPanel === "split" && "cw-split-chat",
pageContentHiddenDesktopVideoOnly && "md:hidden",
showMobileVideoPanel && "max-md:hidden",
)}
bind:this={chatCompose}>
<div class="chat__compose-inner min-w-0 flex-1">
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
@@ -455,20 +564,22 @@
{: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 !$room.isClosed}
{#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}
{/if}
</div>
{:else}
@@ -495,7 +606,7 @@
{/key}
{/if}
</div>
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
{#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"}
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
<VoiceWidget />
</div>
@@ -512,7 +623,7 @@
{#if showFixedNewMessages}
<div class="relative z-popover flex justify-center">
<div transition:fly={{duration: 200}} class="fixed top-12 pt-sai">
<div transition:fly={{duration: 200}} class="fixed top-12">
<Button class="btn btn-primary btn-xs rounded-full" onclick={scrollToNewMessages}>
New Messages
</Button>
+6 -7
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {onMount, tick} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import type {Readable} from "svelte/store"
@@ -109,14 +109,13 @@
const newMessages = document.getElementById("new-messages")
if (newMessagesSeen) {
if (!newMessages || newMessagesSeen) {
showFixedNewMessages = false
} else if (newMessages) {
} else {
const {y} = newMessages.getBoundingClientRect()
if (y > 0 && y < 300) {
if (y > 300) {
newMessagesSeen = true
showFixedNewMessages = false
} else {
showFixedNewMessages = y < 0
}
@@ -232,7 +231,7 @@
elements.reverse()
requestAnimationFrame(manageScrollPosition)
tick().then(manageScrollPosition)
return elements
})
@@ -386,7 +385,7 @@
{#if showFixedNewMessages}
<div class="relative z-popover flex justify-center">
<div transition:fly={{duration: 200}} class="fixed top-12 mt-sai">
<div transition:fly={{duration: 200}} class="fixed top-12">
<Button class="btn btn-primary btn-xs rounded-full" onclick={scrollToNewMessages}>
New Messages
</Button>
+1 -1
View File
@@ -9,7 +9,7 @@ config({path: ".env.template"})
export default {
content: ["./src/**/*.{html,js,svelte,ts}"],
darkMode: ["selector", '[data-theme="dark"]'],
safelist: ["bg-success", "bg-warning", 'w-4 h-4'],
safelist: ["bg-success", "bg-warning"],
theme: {
extend: {},
zIndex: {