Compare commits

..

68 Commits

Author SHA1 Message Date
mplorentz b5dd7dd590 Clean up deriveProfile call in ProfileCircle 2026-03-16 09:57:02 -04:00
mplorentz b34f6b2754 Show VoiceWidget when disconnected but still viewing room page. 2026-03-16 09:57:02 -04:00
Jon Staab 00573580e4 A little bit of cleanup 2026-03-16 09:57:02 -04:00
Jon Staab 6fd2acc332 Revert "Fix a docker rebuild issue (#88)"
This reverts commit bc145f4caf.
2026-03-16 09:57:02 -04:00
Jon Staab 2e1148e514 Prevent shrinkage 2026-03-16 09:57:02 -04:00
Jon Staab cdce8d917d Slight style tweaks to room nav 2026-03-16 09:57:02 -04:00
Jon Staab bbb95ecaa3 Slight style tweaks to room nav 2026-03-16 09:57:02 -04:00
mplorentz 10eb3e71ad Remove info button from voice widgetn 2026-03-16 09:57:02 -04:00
mplorentz b54ec90b33 Expect livekit identity not pubkey in 39004 2026-03-16 09:57:02 -04:00
mplorentz 451a5d5130 Fix scrolling of space menu on mobile 2026-03-16 09:57:02 -04:00
mplorentz c6f11e63a2 Fix build on ios 2026-03-16 09:57:02 -04:00
mplorentz a3f76b8b41 Request microphone permission on ios 2026-03-16 09:57:01 -04:00
mplorentz 8c44eaba72 Don't leave voice room on second click 2026-03-16 09:57:01 -04:00
mplorentz fad369b689 Display voice widget in chat rooms on mobile 2026-03-16 09:57:01 -04:00
mplorentz 3ac3dab628 Fix voice widget layout on mobile 2026-03-16 09:57:01 -04:00
mplorentz bb15011464 Fix voice room icon getting truncated in PageBar 2026-03-16 09:57:01 -04:00
mplorentz 7f88202e18 Integrate new PageBar behavior 2026-03-16 09:54:12 -04:00
mplorentz ca7fe9442a Switch to 39004 for room presence 2026-03-16 09:54:12 -04:00
mplorentz 5b4fcc6c9e Expect HTTP 204 for livekit support 2026-03-16 09:54:12 -04:00
mplorentz 039ebc4ca7 Animate voice widget in and out 2026-03-16 09:54:12 -04:00
mplorentz 93a1fed958 Add join and leave sounds 2026-03-16 09:54:12 -04:00
mplorentz c91a52a31d Remove no-text rooms, highlight active room, fix custom voice room icons 2026-03-16 09:54:12 -04:00
mplorentz fc4e1281d9 Request microphone permissions when unmuting 2026-03-16 09:54:12 -04:00
mplorentz 64617f585b Show custom icon for voice rooms 2026-03-16 09:54:12 -04:00
mplorentz 97b81b2ddd Remove voice-only rooms from "Other rooms" 2026-03-16 09:54:12 -04:00
mplorentz a7421eb789 Add lables on hover for voice room controls 2026-03-16 09:54:12 -04:00
mplorentz 19ef84c9b4 Use joinAbortController as isJoining 2026-03-16 09:54:12 -04:00
mplorentz 630abbbf4e Hide voice rooms on mobile 2026-03-16 09:54:12 -04:00
mplorentz 1c754a10d7 Move unfavorited voice rooms into a new section in the SpaceMenu 2026-03-16 09:54:12 -04:00
mplorentz bc69c0f2e6 Use if let 2026-03-16 09:54:12 -04:00
mplorentz 8303c9c6e2 use new livekit welshman properties 2026-03-16 09:54:12 -04:00
mplorentz 47d6e9f963 Allow joining without a microphone 2026-03-16 09:54:12 -04:00
mplorentz ac543ac5bf Log join errors 2026-03-16 09:54:12 -04:00
mplorentz 84e2e16e49 include room participants inside VoiceRoomItem border 2026-03-16 09:54:12 -04:00
Jon Staab ef020aa8c1 Bump welshman 2026-03-16 09:54:12 -04:00
Jon Staab 616c6beed4 Tweak voice room display 2026-03-16 09:54:12 -04:00
mplorentz 0853ef45e7 Don't show technical error message to the user 2026-03-16 09:54:12 -04:00
mplorentz 5e0531ec92 Revert changes to dockerfile 2026-03-16 09:54:12 -04:00
mplorentz 378aeec7e5 Address remaining PR comments 2026-03-16 09:54:12 -04:00
mplorentz d128eb6c7a Use TrustedEvent 2026-03-16 09:54:12 -04:00
mplorentz 68844226ca Use bell icon for notifications 2026-03-16 09:54:12 -04:00
mplorentz 9c2d2093ec Address PR comments on RoomForm 2026-03-16 09:54:12 -04:00
mplorentz 02b2ccdee3 Reorder imports 2026-03-16 09:54:12 -04:00
mplorentz 2350123136 Add room info button to VoiceWidget 2026-03-16 09:54:11 -04:00
mplorentz 69a01db926 Fix muted icon 2026-03-16 09:54:11 -04:00
mplorentz 46483c7097 Add a right around user avatar when speaking 2026-03-16 09:54:11 -04:00
mplorentz 4ffe26ca56 Move room type field up in the RoomForm 2026-03-16 09:54:11 -04:00
mplorentz 760aecc376 Disable rooms on mobile temporarily 2026-03-16 09:54:11 -04:00
mplorentz be65325122 Check if livekit is configured on the relay during room creation/edit 2026-03-16 09:54:11 -04:00
mplorentz 866dfb1d8f Add ability to joining a voice room while it's in progress 2026-03-16 09:54:11 -04:00
mplorentz 5350dab324 Add loading indicator while joining 2026-03-16 09:54:11 -04:00
mplorentz 9aaeddf066 Add error toast on connection failure. 2026-03-16 09:54:11 -04:00
mplorentz 22e8e3ed32 Make voice rooms in sidebar reactive 2026-03-16 09:54:11 -04:00
mplorentz a16ffa5c31 Get rid of volume icon next to text rooms 2026-03-16 09:54:11 -04:00
mplorentz 68745530f7 Allow user to configure room for voice, text, or both. 2026-03-16 09:54:11 -04:00
mplorentz 8072a64a41 Remove plan 2026-03-16 09:54:11 -04:00
mplorentz f3c5a75445 Move livekit auth to relay 2026-03-16 09:54:11 -04:00
mplorentz c24428e944 Fix logo download during docker build 2026-03-16 09:54:11 -04:00
mplorentz 7949ac8b0b Source .env explicitly during build 2026-03-16 09:54:11 -04:00
mplorentz 70a94717ca Fix issue where docker build would rebuild when app did not change 2026-03-16 09:54:11 -04:00
mplorentz 5aa4221078 Try just serving 404.html 2026-03-16 09:54:11 -04:00
mplorentz 117bf487dc Try rewrites to get SPA mode working 2026-03-16 09:54:11 -04:00
mplorentz 4c753676b0 Serve in SPA mode 2026-03-16 09:54:11 -04:00
mplorentz fc2d4adc21 Auto-play voice track when joining a voice room 2026-03-16 09:54:11 -04:00
mplorentz 1a16a9ec0b Add android microphoen permissions 2026-03-16 09:54:11 -04:00
mplorentz 221e80ca82 ignore pnpm store 2026-03-16 09:54:11 -04:00
mplorentz d8fb794d16 WIP voice channels 2026-03-16 09:54:11 -04:00
mplorentz e614840667 Fix a docker rebuild issue (#88)
The Docker build wasn't making use of docker's cache because the .git directory was being copied into the build context. This means that even if the app did not change, if anything in git changed then docker would rebuild the entire app.

This excludes the .git folder from the docker build, instead relying on the user to pass in the build hash at build time. Which is annoying but I don't think there's a better way around it.

This was annoying me because I am deploying a self-hosted version of flotilla from a git branch via ansible and it was rebuilding flotilla every time.

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: #88
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-03-16 09:54:11 -04:00
79 changed files with 1386 additions and 3074 deletions
+1 -1
View File
@@ -15,7 +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_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_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
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_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY= VITE_GLITCHTIP_API_KEY=
-1
View File
@@ -169,7 +169,6 @@ src/
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss - 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 - 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. - 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):** **Human-First Simplicity (Jon Staab Style):**
+1 -11
View File
@@ -1,19 +1,9 @@
# Changelog # Changelog
# 1.7.0 # Current
* Enable email/password login * Enable email/password login
* Add up/edit to direct messages * 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 # 1.6.5
+1 -1
View File
@@ -6,7 +6,7 @@ If you would like to be interoperable with Flotilla, please check out this guide
## Environment ## Environment
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples): You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust - `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
- `VITE_PLATFORM_URL` - The url where the app will be hosted - `VITE_PLATFORM_URL` - The url where the app will be hosted
+2 -7
View File
@@ -1,5 +1,4 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android { android {
namespace = "social.flotilla" namespace = "social.flotilla"
@@ -8,8 +7,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 42 versionCode 41
versionName "1.7.0" versionName "1.6.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -36,10 +35,6 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" 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') implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
-1
View File
@@ -9,7 +9,6 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':aparajita-capacitor-secure-storage')
implementation project(':capacitor-community-safe-area') implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-filesystem') implementation project(':capacitor-filesystem')
@@ -1,15 +1,5 @@
package social.flotilla; package social.flotilla;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import social.flotilla.notifications.AndroidPushFallbackPlugin; public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(AndroidPushFallbackPlugin.class);
super.onCreate(savedInstanceState);
}
}
@@ -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. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '2.2.20'
repositories { repositories {
google() google()
@@ -10,7 +9,6 @@ buildscript {
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.13.2' classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'com.google.gms:google-services:4.4.4' 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 // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
-3
View File
@@ -2,9 +2,6 @@
include ':capacitor-android' 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') 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' 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') 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_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.0; MARKETING_VERSION = 1.6.5;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -385,14 +385,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.0; MARKETING_VERSION = 1.6.5;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
-1
View File
@@ -11,7 +11,6 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios' 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 '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 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem' pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
+11 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.7.0", "version": "1.6.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -42,7 +42,6 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@capacitor-community/safe-area": "^8.0.1", "@capacitor-community/safe-area": "^8.0.1",
"@capacitor/android": "^8.0.1", "@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
@@ -66,16 +65,16 @@
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8", "@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.8.10", "@welshman/app": "^0.8.9",
"@welshman/content": "^0.8.10", "@welshman/content": "^0.8.9",
"@welshman/editor": "^0.8.10", "@welshman/editor": "^0.8.9",
"@welshman/feeds": "^0.8.10", "@welshman/feeds": "^0.8.9",
"@welshman/lib": "^0.8.10", "@welshman/lib": "^0.8.9",
"@welshman/net": "^0.8.10", "@welshman/net": "^0.8.9",
"@welshman/router": "^0.8.10", "@welshman/router": "^0.8.9",
"@welshman/signer": "^0.8.10", "@welshman/signer": "^0.8.9",
"@welshman/store": "^0.8.10", "@welshman/store": "^0.8.9",
"@welshman/util": "^0.8.10", "@welshman/util": "^0.8.9",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"daisyui": "^4.12.24", "daisyui": "^4.12.24",
"date-picker-svelte": "^2.17.0", "date-picker-svelte": "^2.17.0",
+110 -158
View File
@@ -11,9 +11,6 @@ importers:
.: .:
dependencies: dependencies:
'@aparajita/capacitor-secure-storage':
specifier: ^8.0.0
version: 8.0.0
'@capacitor-community/safe-area': '@capacitor-community/safe-area':
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.0.1(@capacitor/core@8.0.1) version: 8.0.1(@capacitor/core@8.0.1)
@@ -61,7 +58,7 @@ importers:
version: 1.9.7 version: 1.9.7
'@pomade/core': '@pomade/core':
specifier: ^0.2.1 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.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)) 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': '@poppanator/sveltekit-svg':
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1(rollup@2.80.0)(svelte@5.48.0)(svgo@3.3.2)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0)) version: 4.2.1(rollup@2.80.0)(svelte@5.48.0)(svgo@3.3.2)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0))
@@ -84,35 +81,35 @@ importers:
specifier: ^0.6.8 specifier: ^0.6.8
version: 0.6.8(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.48.0)(vite@5.4.21(@types/node@25.0.10)(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)) 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': '@welshman/app':
specifier: ^0.8.10 specifier: ^0.8.9
version: 0.8.10(b1057552692475ccd3b973b40142e1b2) version: 0.8.9(56a9569377ccbc308c0adef0d87b4892)
'@welshman/content': '@welshman/content':
specifier: ^0.8.10 specifier: ^0.8.9
version: 0.8.10(nostr-tools@2.20.0(typescript@5.9.3)) version: 0.8.9(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/editor': '@welshman/editor':
specifier: ^0.8.10 specifier: ^0.8.9
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)) 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': '@welshman/feeds':
specifier: ^0.8.10 specifier: ^0.8.9
version: 0.8.10(d287ec628e3b45481639b01eedf791d2) version: 0.8.9(e7b1650516a86ec271bd2c0f047c2e03)
'@welshman/lib': '@welshman/lib':
specifier: ^0.8.10 specifier: ^0.8.9
version: 0.8.10 version: 0.8.9
'@welshman/net': '@welshman/net':
specifier: ^0.8.10 specifier: ^0.8.9
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) 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': '@welshman/router':
specifier: ^0.8.10 specifier: ^0.8.9
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))) 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': '@welshman/signer':
specifier: ^0.8.10 specifier: ^0.8.9
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)) 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': '@welshman/store':
specifier: ^0.8.10 specifier: ^0.8.9
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) 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': '@welshman/util':
specifier: ^0.8.10 specifier: ^0.8.9
version: 0.8.10(@noble/curves@1.9.7)(@welshman/lib@0.8.10)(nostr-tools@2.20.0(typescript@5.9.3)) 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: compressorjs-next:
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2 version: 1.1.2
@@ -235,10 +232,6 @@ packages:
'@antfu/utils@0.7.10': '@antfu/utils@0.7.10':
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} 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': '@apideck/better-ajv-errors@0.3.6':
resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -763,11 +756,6 @@ packages:
peerDependencies: peerDependencies:
'@capacitor/core': ^8.0.0 '@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': '@capacitor/app@8.0.0':
resolution: {integrity: sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA==} resolution: {integrity: sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA==}
peerDependencies: peerDependencies:
@@ -791,9 +779,6 @@ packages:
'@capacitor/core@8.0.1': '@capacitor/core@8.0.1':
resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==} resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==}
'@capacitor/core@8.2.0':
resolution: {integrity: sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==}
'@capacitor/filesystem@8.1.0': '@capacitor/filesystem@8.1.0':
resolution: {integrity: sha512-AfawIxQ8xBmKsEn/vEpgurGQB9+hFXRtwEiCXR+SSS0MkTw4bJrvLGnloZ/PblegYefvnay1q079Yz3PQ6y1dA==} resolution: {integrity: sha512-AfawIxQ8xBmKsEn/vEpgurGQB9+hFXRtwEiCXR+SSS0MkTw4bJrvLGnloZ/PblegYefvnay1q079Yz3PQ6y1dA==}
peerDependencies: peerDependencies:
@@ -804,11 +789,6 @@ packages:
peerDependencies: peerDependencies:
'@capacitor/core': ^8.0.0 '@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': '@capacitor/keyboard@8.0.0':
resolution: {integrity: sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g==} resolution: {integrity: sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g==}
peerDependencies: peerDependencies:
@@ -2002,83 +1982,83 @@ packages:
'@vite-pwa/assets-generator': '@vite-pwa/assets-generator':
optional: true optional: true
'@welshman/app@0.8.10': '@welshman/app@0.8.9':
resolution: {integrity: sha512-XwcwQ1bfRebbnJK0FHXWo4nPVSrqbvQ/XeiyOpxrY2uz6zTNKRe8ep8/v8m0rPeoJNQN8MNceIzqa+QRUu40Lg==} resolution: {integrity: sha512-2ff0Y9JzSVqJz9qY8vPDY7CC9xBZ5KQPLlVRX2OGnwopmLm9P68i6u8eJG53caxCUv+d7RCDXNlYOkFH6hr7nw==}
peerDependencies: peerDependencies:
'@pomade/core': ^0.2.1 '@pomade/core': ^0.2.1
'@welshman/feeds': 0.8.10 '@welshman/feeds': 0.8.9
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@welshman/net': 0.8.10 '@welshman/net': 0.8.9
'@welshman/router': 0.8.10 '@welshman/router': 0.8.9
'@welshman/signer': 0.8.10 '@welshman/signer': 0.8.9
'@welshman/store': 0.8.10 '@welshman/store': 0.8.9
'@welshman/util': 0.8.10 '@welshman/util': 0.8.9
svelte: ^4.0.0 || ^5.0.0 svelte: ^4.0.0 || ^5.0.0
'@welshman/content@0.8.10': '@welshman/content@0.8.9':
resolution: {integrity: sha512-+5a61ir8Jj0xy0JaqBhuWcq1WhgsT1VQorUVLVjdQZPG5wdFsnVHkSQLib/7MlzUuLeJMQVHFwVvd35iu0kAUg==} resolution: {integrity: sha512-K9r1hAooqM857Ze4i0kF/LSqOZjhuYDsbY07kA1pjbkfrgf8cLuaVP+qicDxGfHD4kKDWuCb/PkUf2kC8nOjuQ==}
peerDependencies: peerDependencies:
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@welshman/editor@0.8.10': '@welshman/editor@0.8.9':
resolution: {integrity: sha512-TDUQHIHAOGoep7I7PdNNtPJ36rZLOppnCHpuYDC7rHZQCvprHW3C8JVrOZQFtbvZIcRf3psrnxnoeBuR/I1OtQ==} resolution: {integrity: sha512-iDBp/qaZBGaaKfSk+7hrJkgzssovZLAkoT5ULjFoBpT9NfpJu/5WYfoUYwKxa6fS9Z84veS39y/PW1LFBNPnpg==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@welshman/util': 0.8.10 '@welshman/util': 0.8.9
nostr-editor: ^1.1.1 nostr-editor: ^1.1.1
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@welshman/feeds@0.8.10': '@welshman/feeds@0.8.9':
resolution: {integrity: sha512-h03YWlbYaa1g9fwDmUXj0BHcp4+Pz40s5soGWlWzZtI7ItnQ61+Y6V/adfOhpXWFDBK1jDA457BkI1eR/dc50A==} resolution: {integrity: sha512-8JI6rrETqDqa9VdU0eEP1OBvTjnampfgs0ZhdOELy9itFcuqDz8wPScnw3sygApL53tkFv4n2xnjZE7E3N3U/w==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@welshman/net': 0.8.10 '@welshman/net': 0.8.9
'@welshman/router': 0.8.10 '@welshman/router': 0.8.9
'@welshman/signer': 0.8.10 '@welshman/signer': 0.8.9
'@welshman/util': 0.8.10 '@welshman/util': 0.8.9
'@welshman/lib@0.8.10': '@welshman/lib@0.8.9':
resolution: {integrity: sha512-QAdyeHIpC8/kl496orZG5Y7H8HX9s4KaxJbkhNhwBzp7HlBmWWvlxJpBVRaLid9Q7ZkTJjWuAu+2d1jAa/rsFg==} resolution: {integrity: sha512-Gk9MXaJNuLL9EguP2RnoaGaQy6x0BrneZfj9gL5t6ZNIF+1g+maJssKDbCRjdDPeuNQbRhh7AlSGSQUJuhkq6Q==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
'@welshman/net@0.8.10': '@welshman/net@0.8.9':
resolution: {integrity: sha512-cQI0EzsvGYe5M7UANHYqRyespTAX5zC4mbFK9uGXXDqjjyvBygVnEbLNDqnO0AIpcI1tEAB2VXyGcfCQFrDdTw==} resolution: {integrity: sha512-0brgfS7pHlE23CmAVLZ/RCGVvyjq+MX4NAhFyqxXuCCcqN7Lf9t60aQ6Z0aKl2dA79yVDtS6d1S53RR1rNS+Ew==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@welshman/util': 0.8.10 '@welshman/util': 0.8.9
'@welshman/router@0.8.10': '@welshman/router@0.8.9':
resolution: {integrity: sha512-3F+C5n7dloxrznelj3rxPJBo9rS2jJO8FS+c12PwZkfGaCX9To35aqRkia/2DiDJO9Dcm4D/D59WyT7/EPRAgQ==} resolution: {integrity: sha512-Kjf7CyO8wvnsVS3TX0eRUVd327F4vsDUdJFpo1MYjKRmgwj7ebOiofY8Fx011BX/GcpVVq7pUFs5792cSjlsrQ==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@welshman/net': 0.8.10 '@welshman/net': 0.8.9
'@welshman/util': 0.8.10 '@welshman/util': 0.8.9
'@welshman/signer@0.8.10': '@welshman/signer@0.8.9':
resolution: {integrity: sha512-JTzOzSbzmgux+WpMMMqeNw3sdzs2PvFLROZ7m8ArWASFrXw6qDE74v7k3Ir5WAMUTxbee+GXRRVHoeE3QFhDIw==} resolution: {integrity: sha512-PVnZn5Rz+10hH35f350JVo1ug3qFeunraOpCcPkmo8XVA0bEEY5503OMGfu1osfEI6KMseoFPU/AdZrh+w+ZQQ==}
version: 0.8.10 version: 0.8.9
peerDependencies: peerDependencies:
'@noble/curves': ^1.9.7 '@noble/curves': ^1.9.7
'@noble/hashes': ^2.0.1 '@noble/hashes': ^2.0.1
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@welshman/net': 0.8.10 '@welshman/net': 0.8.9
'@welshman/util': 0.8.10 '@welshman/util': 0.8.9
nostr-signer-capacitor-plugin: '*' nostr-signer-capacitor-plugin: '*'
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@welshman/store@0.8.10': '@welshman/store@0.8.9':
resolution: {integrity: sha512-hkVZcttU8dIIAZxX19liB0SWNULF//nhjr7xBAhwRcm2rPsFLsW65ofwVx61PsYTrl7BKQ22Z5JUJOdxIVPz0Q==} resolution: {integrity: sha512-wchFOvQB/E5/j5oyqw0QmIx1XzWtm0b3b2mtNUKF7bdtX7YskiSeLcXalJTALD+WkW02cGzBw2SvoJjtDiyWnw==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@welshman/net': 0.8.10 '@welshman/net': 0.8.9
'@welshman/util': 0.8.10 '@welshman/util': 0.8.9
svelte: ^4.0.0 || ^5.0.0 svelte: ^4.0.0 || ^5.0.0
'@welshman/util@0.8.10': '@welshman/util@0.8.9':
resolution: {integrity: sha512-Ur0SKpOIZYGqJiBNAlSyRSkQndQfxx5RBMQQG2ZZw7alZ2ekFCor/fuUn/dsb3X2Bpk5CZYr2Mn+ObVzkYKgLg==} resolution: {integrity: sha512-oOijx0PCsTVhPOPr+5HS4mZFntrtHAW8cdBvJqu/Asf+m6UrvVCeuoF3NDtKhWbkuD6uZnfeJy6WDKVhTptZEA==}
peerDependencies: peerDependencies:
'@noble/curves': ^1.9.7 '@noble/curves': ^1.9.7
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@xml-tools/parser@1.0.11': '@xml-tools/parser@1.0.11':
@@ -5128,14 +5108,6 @@ snapshots:
'@antfu/utils@0.7.10': {} '@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)': '@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)':
dependencies: dependencies:
ajv: 8.18.0 ajv: 8.18.0
@@ -5817,18 +5789,10 @@ snapshots:
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@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)': '@capacitor/app@8.0.0(@capacitor/core@8.0.1)':
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@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)': '@capacitor/assets@3.0.5(@types/node@25.0.10)(typescript@5.9.3)':
dependencies: dependencies:
'@capacitor/cli': 5.7.8 '@capacitor/cli': 5.7.8
@@ -5899,10 +5863,6 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@capacitor/core@8.2.0':
dependencies:
tslib: 2.8.1
'@capacitor/filesystem@8.1.0(@capacitor/core@8.0.1)': '@capacitor/filesystem@8.1.0(@capacitor/core@8.0.1)':
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@capacitor/core': 8.0.1
@@ -5912,18 +5872,10 @@ snapshots:
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@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)': '@capacitor/keyboard@8.0.0(@capacitor/core@8.0.1)':
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@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)': '@capacitor/preferences@8.0.0(@capacitor/core@8.0.1)':
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@capacitor/core': 8.0.1
@@ -6536,15 +6488,15 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@pomade/core@0.2.1(@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: dependencies:
'@frostr/bifrost': 1.0.7(typescript@5.9.3) '@frostr/bifrost': 1.0.7(typescript@5.9.3)
'@noble/hashes': 2.0.1 '@noble/hashes': 2.0.1
'@peculiar/x509': 1.14.3 '@peculiar/x509': 1.14.3
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@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)
'@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))
'@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))
cbor-x: 1.6.0 cbor-x: 1.6.0
hash-wasm: 4.12.0 hash-wasm: 4.12.0
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
@@ -7133,26 +7085,26 @@ snapshots:
optionalDependencies: optionalDependencies:
'@vite-pwa/assets-generator': 0.2.6 '@vite-pwa/assets-generator': 0.2.6
'@welshman/app@0.8.10(b1057552692475ccd3b973b40142e1b2)': '@welshman/app@0.8.9(56a9569377ccbc308c0adef0d87b4892)':
dependencies: dependencies:
'@pomade/core': 0.2.1(@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))
'@welshman/feeds': 0.8.10(d287ec628e3b45481639b01eedf791d2) '@welshman/feeds': 0.8.9(e7b1650516a86ec271bd2c0f047c2e03)
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@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)
'@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)))
'@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))
'@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)
'@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))
fuse.js: 7.1.0 fuse.js: 7.1.0
svelte: 5.48.0 svelte: 5.48.0
throttle-debounce: 5.0.2 throttle-debounce: 5.0.2
'@welshman/content@0.8.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: dependencies:
'@braintree/sanitize-url': 7.1.1 '@braintree/sanitize-url': 7.1.1
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
'@welshman/editor@0.8.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: dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
'@tiptap/extension-code': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) '@tiptap/extension-code': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
@@ -7167,64 +7119,64 @@ snapshots:
'@tiptap/extension-text': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) '@tiptap/extension-text': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
'@tiptap/pm': 2.27.2 '@tiptap/pm': 2.27.2
'@tiptap/suggestion': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) '@tiptap/suggestion': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@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))
nostr-editor: 1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))) nostr-editor: 1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
tippy.js: 6.3.7 tippy.js: 6.3.7
'@welshman/feeds@0.8.10(d287ec628e3b45481639b01eedf791d2)': '@welshman/feeds@0.8.9(e7b1650516a86ec271bd2c0f047c2e03)':
dependencies: dependencies:
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@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)
'@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)))
'@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))
'@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))
trava: 1.2.1 trava: 1.2.1
'@welshman/lib@0.8.10': '@welshman/lib@0.8.9':
dependencies: dependencies:
'@scure/base': 1.2.6 '@scure/base': 1.2.6
'@types/events': 3.0.3 '@types/events': 3.0.3
events: 3.3.0 events: 3.3.0
'@welshman/net@0.8.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: dependencies:
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@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))
events: 3.3.0 events: 3.3.0
isomorphic-ws: 5.0.0(ws@8.18.3) isomorphic-ws: 5.0.0(ws@8.18.3)
transitivePeerDependencies: transitivePeerDependencies:
- ws - ws
'@welshman/router@0.8.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: dependencies:
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@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)
'@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))
'@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: dependencies:
'@noble/curves': 1.9.7 '@noble/curves': 1.9.7
'@noble/hashes': 2.0.1 '@noble/hashes': 2.0.1
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@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)
'@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))
nostr-signer-capacitor-plugin: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1) nostr-signer-capacitor-plugin: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1)
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
'@welshman/store@0.8.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: dependencies:
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
'@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)
'@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))
svelte: 5.48.0 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: dependencies:
'@noble/curves': 1.9.7 '@noble/curves': 1.9.7
'@types/ws': 8.18.1 '@types/ws': 8.18.1
'@welshman/lib': 0.8.10 '@welshman/lib': 0.8.9
js-base64: 3.7.8 js-base64: 3.7.8
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
nostr-wasm: 0.1.0 nostr-wasm: 0.1.0
+1 -1
View File
@@ -20,7 +20,7 @@
{@render children?.()} {@render children?.()}
</PrimaryNav> </PrimaryNav>
{:else if !$modal} {:else if !$modal}
<Dialog noEscape children={{component: Landing, props: {}}} /> <Dialog children={{component: Landing, props: {}}} />
{/if} {/if}
</div> </div>
<Toast /> <Toast />
+31 -21
View File
@@ -34,15 +34,14 @@
messagingRelayListsByPubkey, messagingRelayListsByPubkey,
} from "@welshman/app" } from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl" 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 Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
@@ -52,7 +51,7 @@
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte" import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte" import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.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 {pushModal} from "@app/util/modal"
import {makeDelete, prependParent} from "@app/core/commands" import {makeDelete, prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -66,15 +65,13 @@
const chat = deriveChat(pubkeys) const chat = deriveChat(pubkeys)
const others = remove($pubkey!, 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 = () => const showMembers = () =>
others.length === 1 others.length === 1
? pushModal(ProfileDetail, {pubkey: others[0]}) ? pushModal(ProfileDetail, {pubkey: others[0]})
: pushModal(ChatMembers, {pubkeys: others}) : pushModal(ChatMembers, {pubkeys: others})
const back = () => history.back()
const replyTo = (event: TrustedEvent) => { const replyTo = (event: TrustedEvent) => {
parent = event parent = event
compose?.focus() compose?.focus()
@@ -106,7 +103,6 @@
await sendWrapped({ await sendWrapped({
event: makeDelete({event: eventToEdit, protect: false}), event: makeDelete({event: eventToEdit, protect: false}),
recipients: pubkeys, recipients: pubkeys,
pow: 16,
}) })
} }
@@ -156,7 +152,6 @@
event, event,
recipients: pubkeys, recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i), delay: $userSettingsValues.send_delay + ms(i),
pow: 16,
}), }),
), ),
) )
@@ -254,10 +249,6 @@
</script> </script>
<PageBar> <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="flex items-center justify-between gap-4">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap"> <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}> <Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
@@ -288,25 +279,45 @@
{/if} {/if}
</Button> </Button>
</div> </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> </div>
{/if}
</div> </div>
</PageBar> </PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4"> <PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div> <div bind:this={dynamicPadding}></div>
{#if missingRelayLists.length > 0} {#if missingRelayLists.includes($pubkey!)}
<div class="py-12"> <div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center"> <div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error"> <p class="row-2 text-lg text-error">
<Icon icon={Danger} /> <Icon icon={Danger} />
Direct messages are not enabled Your messaging relays are not configured.
</p> </p>
<p> <p>
Ask In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
{#each missingRelayLists as pubkey (pubkey)} your <Link class="link" href="/settings/relays">relay settings page</Link> to receive messages.
<ProfileLink {pubkey} /> </p>
{/each} </div>
to enable direct messaging by opening this conversation in their app. </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> </p>
</div> </div>
</div> </div>
@@ -351,7 +362,6 @@
{onSubmit} {onSubmit}
{onEscape} {onEscape}
{onEditPrevious} {onEditPrevious}
content={eventToEdit?.content} content={eventToEdit?.content} />
disabled={Boolean(missingRelayLists.length)} />
{/key} {/key}
</div> </div>
+6 -14
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import {onDestroy, onMount} from "svelte" import {onDestroy, onMount} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import cx from "classnames"
import type {EventContent} from "@welshman/util" import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl" import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@@ -13,24 +12,17 @@
type Props = { type Props = {
content?: string content?: string
disabled?: boolean
onEscape?: () => void onEscape?: () => void
onEditPrevious?: () => void onEditPrevious?: () => void
onSubmit: (event: EventContent) => 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 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 focus = () => editor.then(ed => ed.chain().focus().run())
export const canEnterEditPrevious = () => export const canEnterEditPrevious = () =>
@@ -49,7 +41,7 @@
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run()) const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => { const submit = async () => {
if ($uploading || disabled) return if ($uploading) return
const ed = await editor const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim() const content = ed.getText({blockSeparator: "\n"}).trim()
@@ -86,7 +78,7 @@
<Button <Button
data-tip="Add an image" 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" 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}> onclick={uploadFiles}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
@@ -94,13 +86,13 @@
<Icon icon={GallerySend} /> <Icon icon={GallerySend} />
{/if} {/if}
</Button> </Button>
<div class={editorClass} aria-disabled={disabled}> <div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
<Button <Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send" 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" class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading || disabled} disabled={$uploading}
onclick={submit}> onclick={submit}>
<Icon icon={Plane} /> <Icon icon={Plane} />
</Button> </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 type {TrustedEvent} from "@welshman/util"
import {pubkey, loadMessagingRelayList} from "@welshman/app" import {pubkey, loadMessagingRelayList} from "@welshman/app"
import {fade} from "@lib/transition" 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 ProfileName from "@app/components/ProfileName.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.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" import {notifications} from "@app/util/notifications"
interface Props { interface Props {
@@ -24,7 +24,6 @@
const others = uniq(remove($pubkey!, props.pubkeys)) const others = uniq(remove($pubkey!, props.pubkeys))
const active = $derived($page.params.chat === props.id) const active = $derived($page.params.chat === props.id)
const path = makeChatPath(props.pubkeys) const path = makeChatPath(props.pubkeys)
const openChat = () => goToChat(props.pubkeys)
onMount(() => { onMount(() => {
for (const pk of others) { for (const pk of others) {
@@ -33,7 +32,7 @@
}) })
</script> </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 <div
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}" class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
class:bg-base-100={active}> class:bg-base-100={active}>
@@ -72,4 +71,4 @@
</p> </p>
</div> </div>
</div> </div>
</Button> </Link>
+2 -6
View File
@@ -40,14 +40,10 @@
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
const deleteReaction = (event: TrustedEvent) => 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) => const createReaction = (template: EventContent) =>
sendWrapped({ sendWrapped({event: makeReaction({event, protect: false, ...template}), recipients: pubkeys})
event: makeReaction({event, protect: false, ...template}),
recipients: pubkeys,
pow: 16,
})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@@ -18,7 +18,6 @@
sendWrapped({ sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}), event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys, recipients: pubkeys,
pow: 16,
}) })
</script> </script>
@@ -31,7 +31,6 @@
sendWrapped({ sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}), event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys, recipients: pubkeys,
pow: 16,
}) })
}).bind(undefined, event, pubkeys) }).bind(undefined, event, pubkeys)
+3 -2
View File
@@ -2,6 +2,7 @@
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {onMount} from "svelte" import {onMount} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {tryCatch, uniq} from "@welshman/lib" import {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util" import {fromNostrURI} from "@welshman/util"
import {loadMessagingRelayList} from "@welshman/app" import {loadMessagingRelayList} from "@welshman/app"
@@ -18,11 +19,11 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.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 back = () => history.back()
const onSubmit = () => goToChat(pubkeys) const onSubmit = () => goto(makeChatPath(pubkeys))
const addPubkey = (pubkey: string) => { const addPubkey = (pubkey: string) => {
pubkeys = uniq([...pubkeys, pubkey]) pubkeys = uniq([...pubkeys, pubkey])
+1 -1
View File
@@ -20,7 +20,7 @@
const title = getTagValue("title", event.tags) const title = getTagValue("title", event.tags)
const h = getTagValue("h", 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) || [] const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
</script> </script>
+11 -11
View File
@@ -28,42 +28,42 @@
<Profile inert pubkey={$pubkey} /> <Profile inert pubkey={$pubkey} />
</Link> </Link>
{/if} {/if}
<div class="grid grid-cols-3 gap-3 w-full"> <div class="grid grid-cols-2 gap-3 w-full">
<Link <Link
replaceState replaceState
href="/settings/alerts" href="/settings/alerts"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center"> class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Bell} size={5} /> <Icon icon={Bell} size={7} />
Alerts Alerts
</Link> </Link>
{#if Capacitor.getPlatform() !== "ios"} {#if Capacitor.getPlatform() !== "ios"}
<Link <Link
replaceState replaceState
href="/settings/wallet" href="/settings/wallet"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center"> class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Wallet} size={5} /> <Icon icon={Wallet} size={7} />
Wallet Wallet
</Link> </Link>
{/if} {/if}
<Link <Link
replaceState replaceState
href="/settings/relays" href="/settings/relays"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center"> class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Server} size={5} /> <Icon icon={Server} size={7} />
Relays Relays
</Link> </Link>
<Link <Link
replaceState replaceState
href="/settings/content" href="/settings/content"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center"> class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={GalleryMinimalistic} size={5} /> <Icon icon={GalleryMinimalistic} size={7} />
Content Content
</Link> </Link>
<Link <Link
replaceState replaceState
href="/settings/privacy" href="/settings/privacy"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center"> class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Shield} size={5} /> <Icon icon={Shield} size={7} />
Privacy Privacy
</Link> </Link>
</div> </div>
-1
View File
@@ -42,7 +42,6 @@
target: element, target: element,
props: { props: {
onClose: closeModal, onClose: closeModal,
noEscape: options.noEscape,
fullscreen: options.fullscreen, fullscreen: options.fullscreen,
children: {component, props}, children: {component, props},
}, },
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {notificationSettings} from "@app/core/state" import {notificationSettings} from "@app/core/state"
import {onNotification} from "@app/util/push" import {onNotification} from "@app/util/notifications"
let audioElement: HTMLAudioElement let audioElement: HTMLAudioElement
+6 -8
View File
@@ -3,6 +3,7 @@
import {userProfile} from "@welshman/app" import {userProfile} from "@welshman/app"
import Letter from "@assets/icons/letter.svg?dataurl" import Letter from "@assets/icons/letter.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.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 Planet from "@assets/icons/planet-3.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl" import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl" import Settings from "@assets/icons/settings.svg?dataurl"
@@ -14,7 +15,7 @@
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state" import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
import {goToChat} from "@app/util/routes" import {goToLastChat} from "@app/util/routes"
type Props = { type Props = {
children?: Snippet children?: Snippet
@@ -22,8 +23,6 @@
const {children}: Props = $props() const {children}: Props = $props()
const chatHandler = () => goToChat()
const showSettingsMenu = () => pushModal(MenuSettings) const showSettingsMenu = () => pushModal(MenuSettings)
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p))) const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
@@ -50,7 +49,7 @@
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem <PrimaryNavItem
title="Messages" title="Messages"
onclick={chatHandler} onclick={goToLastChat}
class="tooltip-right" class="tooltip-right"
notification={$notifications.has("/chat")}> notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} /> <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"> 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="content-padding-x content-sizing flex justify-between px-2">
<div class="flex gap-2 sm:gap-6"> <div class="flex gap-2 sm:gap-6">
<PrimaryNavItem title="Search" href="/people"> <PrimaryNavItem title="Home" href="/home">
<ImageIcon alt="Search" src={Magnifier} size={8} /> <ImageIcon alt="Home" src={HomeSmile} size={8} />
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem <PrimaryNavItem
title="Messages" title="Messages"
href="/chat" onclick={goToLastChat}
onclick={chatHandler}
notification={$notifications.has("/chat")}> notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} /> <ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem> </PrimaryNavItem>
+2 -12
View File
@@ -27,33 +27,23 @@
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const openProfile = () => { const openProfile = () => {
if (!inert) {
pushModal(ProfileDetail, {pubkey, url}) pushModal(ProfileDetail, {pubkey, url})
} }
}
const copyPubkey = () => clip(nip19.npubEncode(pubkey)) const copyPubkey = () => clip(nip19.npubEncode(pubkey))
</script> </script>
<div class="flex max-w-full items-start gap-3"> <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"> <Button onclick={openProfile} class="py-1">
<ProfileCircle {pubkey} size={avatarSize} /> <ProfileCircle {pubkey} size={avatarSize} />
</Button> </Button>
{/if}
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2"> <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"> <Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay} {$profileDisplay}
</Button> </Button>
{/if}
<WotScore {pubkey} /> <WotScore {pubkey} />
</div> </div>
{#if $handle} {#if $handle}
+9 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {removeUndefined} from "@welshman/lib" import {removeUndefined} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util" import {ManagementMethod} from "@welshman/util"
import { import {
@@ -29,10 +30,9 @@
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state" import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {goToChat} from "@app/util/routes" import {makeChatPath} from "@app/util/routes"
export type Props = { export type Props = {
pubkey: string pubkey: string
@@ -51,9 +51,11 @@
const back = () => history.back() const back = () => history.back()
const chatPath = makeChatPath([pubkey])
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event}) const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
const openChat = () => goToChat([pubkey]) const openChat = () => goto(chatPath)
const toggleMenu = (pubkey: string) => { const toggleMenu = (pubkey: string) => {
showMenu = !showMenu showMenu = !showMenu
@@ -83,7 +85,10 @@
}) })
const restoreMember = async () => { const restoreMember = async () => {
const error = await addSpaceMembers(url!, [pubkey]) const {error} = await manageRelay(url!, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
-6
View File
@@ -6,7 +6,6 @@
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.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 {errorMessage} from "@lib/util"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -71,11 +70,6 @@
Remove Remove
</Button> </Button>
</RelayItem> </RelayItem>
{:else}
<p class="text-center py-12 flex justify-center items-center gap-2">
<Icon icon={DangerTriangle} />
No relay selections found.
</p>
{/each} {/each}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
@@ -1,26 +1,27 @@
<script lang="ts" module>
export type ActionItem = {
title: string
subtitle: string
action: string
apply: () => unknown
}
</script>
<script lang="ts"> <script lang="ts">
import Stars from "@assets/icons/stars.svg?dataurl" import Stars from "@assets/icons/stars.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import type {HealthCheck} from "@app/util/health"
import {applyHealthCheck} from "@app/util/health"
type Props = { const {title, action, subtitle, apply}: ActionItem = $props()
healthCheck: HealthCheck
}
const {healthCheck}: Props = $props()
const apply = () => applyHealthCheck(healthCheck)
</script> </script>
<div class="card2 card2-sm bg-alt flex justify-between"> <div class="card2 card2-sm bg-alt flex justify-between">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<strong>{healthCheck.title}</strong> <strong>{title}</strong>
<p class="text-sm">{healthCheck.description}</p> <p class="text-sm">{subtitle}</p>
</div> </div>
<Button class="btn btn-neutral btn-sm" onclick={apply}> <Button class="btn btn-neutral btn-sm" onclick={apply}>
<Icon icon={Stars} /> <Icon icon={Stars} />
{healthCheck.action} {action}
</Button> </Button>
</div> </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 back = () => history.back()
const onResolved = () => { const onDelete = () => {
if ($reports.size === 0) { if ($reports.size === 0) {
back() back()
} }
@@ -40,7 +40,7 @@
<ModalSubtitle>All reports for this event are shown below.</ModalSubtitle> <ModalSubtitle>All reports for this event are shown below.</ModalSubtitle>
</ModalHeader> </ModalHeader>
{#each $reports.values() as report (report.id)} {#each $reports.values() as report (report.id)}
<ReportItem {url} event={report} {onResolved} /> <ReportItem {url} event={report} {onDelete} />
{/each} {/each}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+3 -3
View File
@@ -15,10 +15,10 @@
type Props = { type Props = {
url: string url: string
event: TrustedEvent 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 etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags) const ptag = getTag("p", event.tags)
@@ -45,7 +45,7 @@
{/if} {/if}
</span> </span>
</div> </div>
<ReportMenu {url} {event} {onResolved} /> <ReportMenu {url} {event} {onDelete} />
</div> </div>
{#if event.content} {#if event.content}
<div class="border-l-2 border-primary pl-3"> <div class="border-l-2 border-primary pl-3">
+6 -6
View File
@@ -20,10 +20,10 @@
type Props = { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
onResolved?: () => void onDelete?: () => void
} }
const {url, event, onResolved}: Props = $props() const {url, event, onDelete}: Props = $props()
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url) const userIsAdmin = deriveUserIsSpaceAdmin(url)
@@ -40,7 +40,7 @@
const deleteReport = async () => { const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect}) publishDelete({event, relays: [url], protect: await shouldProtect})
onResolved?.() onDelete?.()
} }
const dismissReport = async () => { const dismissReport = async () => {
@@ -54,7 +54,7 @@
} else { } else {
pushToast({message: "Content has successfully been deleted!"}) pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id) repository.removeEvent(event.id)
onResolved?.() onDelete?.()
} }
} }
@@ -77,7 +77,7 @@
repository.removeEvent(event.id) repository.removeEvent(event.id)
repository.removeEvent(id) repository.removeEvent(id)
history.back() history.back()
setTimeout(() => onResolved?.()) setTimeout(() => onDelete?.())
} }
}, },
}) })
@@ -101,7 +101,7 @@
pushToast({message: "User has successfully been banned!"}) pushToast({message: "User has successfully been banned!"})
repository.removeEvent(event.id) repository.removeEvent(event.id)
history.back() history.back()
setTimeout(() => onResolved?.()) setTimeout(() => onDelete?.())
} }
}, },
}) })
+1 -1
View File
@@ -208,7 +208,7 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isClosed} /> <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> </div>
</ModalBody> </ModalBody>
{@render footer({loading})} {@render footer({loading})}
+9 -1
View File
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import Hashtag from "@assets/icons/hashtag.svg?dataurl" import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Volume from "@assets/icons/volume.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 Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
import {deriveRoom} from "@app/core/state" import {deriveRoom} from "@app/core/state"
import {currentVoiceSession} from "@app/voice"
interface Props { interface Props {
h: string h: string
@@ -16,11 +18,17 @@
const room = deriveRoom(url, h) const room = deriveRoom(url, h)
const isVoiceRoom = $derived($room.livekit) const isVoiceRoom = $derived($room.livekit)
const isVoiceRoomActive = $derived(
$currentVoiceSession?.url === url && $currentVoiceSession?.h === h,
)
</script> </script>
{#if isVoiceRoom} {#if isVoiceRoom}
<div class="flex shrink-0 items-center gap-1.5"> <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} {#if $room.picture}
<span class="text-base">/</span> <span class="text-base">/</span>
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" /> <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>
+28 -7
View File
@@ -2,8 +2,9 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {setKey, popKey} from "@lib/implicit" import {setKey, popKey} from "@lib/implicit"
import {sleep} from "@welshman/lib" import {sleep} from "@welshman/lib"
import {displayProfileByPubkey} from "@welshman/app" import {ManagementMethod} from "@welshman/util"
import type {PublishedRoomMeta} 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 AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -21,7 +22,6 @@
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {deriveRoom, deriveSpaceMembers} from "@app/core/state" import {deriveRoom, deriveSpaceMembers} from "@app/core/state"
import {addRoomMembers} from "@app/core/commands"
interface Props { interface Props {
url: string url: string
@@ -42,14 +42,35 @@
// Show loading for auto submit callback // Show loading for auto submit callback
await sleep(500) 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],
}),
),
)
for (const {error} of results) {
if (error) { if (error) {
pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
} else { }
}
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!"}) pushToast({message: "Members have successfully been added!"})
back() back()
}
} finally { } finally {
loading = false 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 { } else {
const permissions = await Push.request() const permissions = await Push.request()
if (permissions.startsWith("granted")) { if (permissions === "granted") {
await setSpaceNotifications(url, true) await setSpaceNotifications(url, true)
} }
} }
+1 -1
View File
@@ -48,7 +48,7 @@
} else { } else {
const permissions = await Push.request() const permissions = await Push.request()
if (permissions.startsWith("granted")) { if (permissions === "granted") {
await setSpaceNotifications(url, true) await setSpaceNotifications(url, true)
} }
} }
+15 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <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 AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -12,7 +13,6 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte" import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
interface Props { interface Props {
@@ -27,14 +27,23 @@
loading = true loading = true
try { try {
const error = await addSpaceMembers(url, pubkeys) const results = await Promise.all(
pubkeys.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
for (const {error} of results) {
if (error) { if (error) {
pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
} else { }
}
pushToast({message: "Members have successfully been added!"}) pushToast({message: "Members have successfully been added!"})
back() back()
}
} finally { } finally {
loading = false loading = false
} }
+4 -2
View File
@@ -17,7 +17,6 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state" import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
interface Props { interface Props {
@@ -56,7 +55,10 @@
} }
const restoreMember = async (pubkey: string) => { const restoreMember = async (pubkey: string) => {
const error = await addSpaceMembers(url, [pubkey]) const {error} = await manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
+35 -40
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util" import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, REPORT} from "@welshman/util"
import {deriveRelay, createSearch, pubkey} from "@welshman/app" import {deriveRelay, createSearch, pubkey} from "@welshman/app"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -34,7 +35,7 @@
import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import SpaceMembers from "@app/components/SpaceMembers.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 RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte" import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte"
@@ -51,15 +52,13 @@
deriveUserCanCreateRoom, deriveUserCanCreateRoom,
deriveUserIsSpaceAdmin, deriveUserIsSpaceAdmin,
deriveEventsForUrl, deriveEventsForUrl,
deriveSpaceActionItems,
notificationSettings, notificationSettings,
deriveShouldNotify, deriveShouldNotify,
displayRoom, displayRoom,
} from "@app/core/state" } from "@app/core/state"
import {setSpaceNotifications} from "@app/core/commands" import {setSpaceNotifications} from "@app/core/commands"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {makeSpacePath, goToChat} from "@app/util/routes" import {makeSpacePath, makeChatPath} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
const {url} = $props() const {url} = $props()
@@ -74,7 +73,7 @@
const otherVoiceRooms = deriveOtherVoiceRooms(url) const otherVoiceRooms = deriveOtherVoiceRooms(url)
const members = deriveSpaceMembers(url) const members = deriveSpaceMembers(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url) const userIsAdmin = deriveUserIsSpaceAdmin(url)
const actionItems = deriveSpaceActionItems(url) const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
const spaceKinds = derived( const spaceKinds = derived(
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]), deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
@@ -99,23 +98,21 @@
showMenu = !showMenu 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 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 addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
const contactOwner = () => goToChat([$relay!.pubkey!])
const shouldNotify = deriveShouldNotify(url) const shouldNotify = deriveShouldNotify(url)
@@ -131,22 +128,23 @@
let term = $state("") let term = $state("")
let showMenu = $state(false) let showMenu = $state(false)
let replaceState = $state(false)
let element: Element | undefined = $state() let element: Element | undefined = $state()
onMount(() => {
replaceState = Boolean(element?.closest(".drawer"))
})
</script> </script>
<div bind:this={element} class="flex min-h-0 flex-1 flex-col"> <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"> <SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<Button <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}> onclick={openMenu}>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<strong class="flex items-center gap-1 relative"> <strong class="ellipsize flex items-center gap-1">
<RelayName {url} class="ellipsize" /> <RelayName {url} />
<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>
{#if $notificationSettings.push && !$shouldNotify} {#if $notificationSettings.push && !$shouldNotify}
<Icon icon={BellOff} size={3} class="opacity-50" /> <Icon icon={BellOff} size={3} class="opacity-50" />
{/if} {/if}
@@ -180,21 +178,18 @@
</li> </li>
{#if $userIsAdmin} {#if $userIsAdmin}
<li> <li>
<Button onclick={showActionItems}> <Button onclick={showReports}>
<Icon icon={Danger} /> <Icon icon={Danger} />
Action Items ({$actionItems.length}) View Reports ({$reports.length})
{#if $actionItems.length > 0}
<div class="h-2 w-2 rounded-full bg-primary"></div>
{/if}
</Button> </Button>
</li> </li>
{/if} {/if}
{#if $relay?.pubkey && $relay.pubkey !== $pubkey} {#if $relay?.pubkey && $relay.pubkey !== $pubkey}
<li> <li>
<Button onclick={contactOwner}> <Link href={makeChatPath([$relay.pubkey])}>
<Icon icon={Letter} /> <Icon icon={Letter} />
Contact Owner Contact Owner
</Button> </Link>
</li> </li>
{/if} {/if}
<li> <li>
@@ -229,31 +224,31 @@
</div> </div>
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden"> <div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
{#if hasNip29($relay)} {#if hasNip29($relay)}
<SecondaryNavItem href={makeSpacePath(url, "recent")}> <SecondaryNavItem {replaceState} href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity <Icon icon={History} /> Recent Activity
</SecondaryNavItem> </SecondaryNavItem>
{:else} {:else}
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}> <SecondaryNavItem {replaceState} href={chatPath}>
<Icon icon={ChatRound} /> Chat <Icon icon={ChatRound} /> Chat
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)} {#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem href={goalsPath}> <SecondaryNavItem {replaceState} href={goalsPath}>
<Icon icon={StarFallMinimalistic} /> Goals <Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(THREAD)} {#if $spaceKinds.has(THREAD)}
<SecondaryNavItem href={threadsPath}> <SecondaryNavItem {replaceState} href={threadsPath}>
<Icon icon={NotesMinimalistic} /> Threads <Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(CLASSIFIED)} {#if $spaceKinds.has(CLASSIFIED)}
<SecondaryNavItem href={classifiedsPath}> <SecondaryNavItem {replaceState} href={classifiedsPath}>
<Icon icon={CaseMinimalistic} /> Classifieds <Icon icon={CaseMinimalistic} /> Classifieds
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(EVENT_TIME)} {#if $spaceKinds.has(EVENT_TIME)}
<SecondaryNavItem href={calendarPath}> <SecondaryNavItem {replaceState} href={calendarPath}>
<Icon icon={CalendarMinimalistic} /> Calendar <Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
@@ -263,7 +258,7 @@
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader> <SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if} {/if}
{#each $userRooms as h (h)} {#each $userRooms as h (h)}
<SpaceMenuRoomItem {url} {h} /> <SpaceMenuRoomItem notify {replaceState} {url} {h} />
{/each} {/each}
{#if $otherRooms.length > 0} {#if $otherRooms.length > 0}
<div class="h-2 flex-shrink-0"></div> <div class="h-2 flex-shrink-0"></div>
@@ -282,17 +277,17 @@
</label> </label>
{/if} {/if}
{#each $roomSearch.searchValues(term) as h (h)} {#each $roomSearch.searchValues(term) as h (h)}
<SpaceMenuRoomItem {url} {h} /> <SpaceMenuRoomItem {replaceState} {url} {h} />
{/each} {/each}
{#if $otherVoiceRooms.length > 0} {#if $otherVoiceRooms.length > 0}
<div class="h-2 flex-shrink-0"></div> <div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader> <SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)} {#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {url} {h} /> <SpaceMenuRoomItem {replaceState} {url} {h} />
{/each} {/each}
{/if} {/if}
{#if $canCreateRoom} {#if $canCreateRoom}
<SecondaryNavItem onclick={addRoom}> <SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Create room Create room
</SecondaryNavItem> </SecondaryNavItem>
@@ -302,7 +297,7 @@
</div> </div>
</SecondaryNavSection> </SecondaryNavSection>
<div <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 /> <VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}> <Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
<SocketStatusIndicator {url} /> <SocketStatusIndicator {url} />
+7 -4
View File
@@ -12,10 +12,11 @@
interface Props { interface Props {
url: any url: any
h: any h: any
notify?: boolean
replaceState?: 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 room = deriveRoom(url, h)
const roomType = $derived(getRoomType($room)) const roomType = $derived(getRoomType($room))
@@ -23,13 +24,15 @@
const shouldNotifyForSpace = deriveShouldNotify(url) const shouldNotifyForSpace = deriveShouldNotify(url)
const shouldNotifyForRoom = deriveShouldNotify(url, h) const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace) const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
</script> </script>
{#if roomType === RoomType.Voice} {#if roomType === RoomType.Voice}
<VoiceRoomItem {url} {h} {replaceState} {notification} /> <VoiceRoomItem {url} {h} {replaceState} />
{:else} {:else}
<SecondaryNavItem href={path} {replaceState} {notification}> <SecondaryNavItem
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
<RoomNameWithImage {url} {h} /> <RoomNameWithImage {url} {h} />
{#if showDifferenceIcon} {#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" /> <Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
+2 -2
View File
@@ -23,7 +23,7 @@
const back = () => history.back() const back = () => history.back()
const onResolved = () => { const onDelete = () => {
if ($reports.length === 0) { if ($reports.length === 0) {
back() back()
} }
@@ -38,7 +38,7 @@
</ModalHeader> </ModalHeader>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each $reports as event (event.id)} {#each $reports as event (event.id)}
<ReportItem {url} {event} {onResolved} /> <ReportItem {url} {event} {onDelete} />
{:else} {:else}
<p class="py-12 text-center">No reports found.</p> <p class="py-12 text-center">No reports found.</p>
{/each} {/each}
+12 -15
View File
@@ -1,18 +1,15 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {goto} from "$app/navigation"
import {loadProfile, displayProfileByPubkey} from "@welshman/app" import {loadProfile, displayProfileByPubkey} from "@welshman/app"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomImage from "@app/components/RoomImage.svelte" import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import {pushToast} from "@app/util/toast"
import {makeRoomPath} from "@app/util/routes" 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 { import {
VoiceState,
deriveVoiceParticipants, deriveVoiceParticipants,
joinVoiceRoom,
cancelJoinVoiceRoom, cancelJoinVoiceRoom,
currentVoiceRoom, currentVoiceRoom,
voiceState, voiceState,
@@ -25,31 +22,32 @@
url: string url: string
h: string h: string
replaceState?: boolean 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 participants = deriveVoiceParticipants(url, h)
const isActive = $derived( const isActive = $derived(
$voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h), $voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
) )
const isJoining = $derived( 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 (isActive) return
if (isJoining) { if (isJoining) {
e.preventDefault()
cancelJoinVoiceRoom() cancelJoinVoiceRoom()
return return
} }
e.preventDefault() try {
await goto(makeRoomPath(url, h), {replaceState}) await joinVoiceRoom(url, h)
pushModal(VoiceRoomJoinDialog, {url, h}) } catch (e) {
console.error("Failed to join voice room", e)
pushToast({theme: "error", message: "Failed to join voice room"})
}
} }
$effect(() => { $effect(() => {
@@ -62,7 +60,6 @@
<SecondaryNavItem <SecondaryNavItem
href={makeRoomPath(url, h)} href={makeRoomPath(url, h)}
{replaceState} {replaceState}
{notification}
onclick={handleClick} onclick={handleClick}
class={cx("!items-start", isActive && "!bg-base-100 !text-base-content")}> class={cx("!items-start", isActive && "!bg-base-100 !text-base-content")}>
<div class="flex w-full min-w-0 flex-col gap-2"> <div class="flex 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>
+11 -51
View File
@@ -1,8 +1,5 @@
<script lang="ts"> <script lang="ts">
import {readable} from "svelte/store"
import {fly} from "svelte/transition" import {fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl" import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl" import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
@@ -11,69 +8,32 @@
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte" import {displayRoom} from "@app/core/state"
import { 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, currentVoiceSession,
currentVoiceRoom, currentVoiceRoom,
voiceState, voiceState,
leaveVoiceRoom, leaveVoiceRoom,
toggleMute, toggleMute,
rejoinVoiceRoom,
cancelJoinVoiceRoom, cancelJoinVoiceRoom,
} from "@app/voice" } from "@app/voice"
const {relay, h} = $derived($page.params) const roomName = $derived(
const url = $derived(relay ? decodeRelay(relay) : undefined) $currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "",
const displayedRoomStore = $derived(
url && h && typeof h === "string" ? deriveRoom(url, h) : readable(undefined),
) )
const routeDisplayedRoom = $derived($displayedRoomStore) const spaceName = $derived($currentVoiceRoom ? displayRelayUrl($currentVoiceRoom.url) : "")
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})
}
</script> </script>
{#if targetRoom} {#if $currentVoiceRoom}
<div <div
in:fly={{y: 60, duration: 350}} in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}} out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3"> class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
{#if $voiceState === VoiceState.Joining} {#if $voiceState === "joining"}
<span class="text-sm font-semibold text-warning">Joining...</span> <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> <span class="text-sm font-semibold text-success">Voice Connected</span>
{:else} {:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span> <span class="text-sm font-semibold text-neutral-content">Disconnected</span>
@@ -83,7 +43,7 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{#if $voiceState === VoiceState.Joining} {#if $voiceState === "joining"}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
<Button <Button
data-tip="Cancel" data-tip="Cancel"
@@ -91,7 +51,7 @@
onclick={cancelJoinVoiceRoom}> onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={4} /> <Icon icon={CloseCircle} size={4} />
</Button> </Button>
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession} {:else if $voiceState === "connected" && $currentVoiceSession}
<Button <Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"} data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
@@ -110,7 +70,7 @@
<Button <Button
data-tip="Join Voice" data-tip="Join Voice"
class="center tooltip tooltip-top btn btn-sm btn-square btn-success" class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
onclick={openJoinDialog}> onclick={rejoinVoiceRoom}>
<Icon icon={PhoneCallingRounded} size={4} /> <Icon icon={PhoneCallingRounded} size={4} />
</Button> </Button>
{/if} {/if}
+5 -68
View File
@@ -17,7 +17,7 @@ import {
} from "@welshman/lib" } from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer" import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor" import type {UploadTask} from "@welshman/editor"
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util" import type {TrustedEvent, EventContent, Profile} from "@welshman/util"
import { import {
DELETE, DELETE,
REPORT, REPORT,
@@ -52,7 +52,6 @@ import {
editProfile, editProfile,
createProfile, createProfile,
uniqTags, uniqTags,
ManagementMethod,
} from "@welshman/util" } from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net" import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
@@ -73,9 +72,6 @@ import {
getPubkeyRelays, getPubkeyRelays,
userBlossomServerList, userBlossomServerList,
getThunkError, getThunkError,
addRoomMember,
manageRelay,
getRelay,
} from "@welshman/app" } from "@welshman/app"
import {compressFile} from "@lib/html" import {compressFile} from "@lib/html"
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state" import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
@@ -93,7 +89,6 @@ import {
stripPrefix, stripPrefix,
relaysMostlyRestricted, relaysMostlyRestricted,
deriveSocket, deriveSocket,
deriveSpaceMembers,
} from "@app/core/state" } from "@app/core/state"
// Utils // 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 (shouldIgnoreError(error)) return
if (!claim && error.includes("invite code size")) 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 normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http"))
export const fetchHasBlossomSupport = async (url: string) => { 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 server = normalizeBlossomUrl(url)
const $signer = signer.get() || Nip01Signer.ephemeral() const $signer = signer.get() || Nip01Signer.ephemeral()
const headers: Record<string, string> = { 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 res = await uploadBlob(server, file, {authEvent})
const text = await res.text() const text = await res.text()
let task let {uploaded, url, ...task} = parseJson(text) || {}
try {
task = parseJson(text)
} catch (e) {
return {error: text}
}
if (!task?.uploaded) { if (!uploaded) {
return {error: text || `Failed to upload file (HTTP ${res.status})`} 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 // Always append correct file extension if we encrypted the file, or if it's missing
let url = task.url
if (options.encrypt) { if (options.encrypt) {
url = url.replace(/\.\w+$/, "") + ext url = url.replace(/\.\w+$/, "") + ext
} else if (new URL(url).pathname.split(".").length === 1) { } else if (new URL(url).pathname.split(".").length === 1) {
@@ -715,50 +699,3 @@ export const updateProfile = ({
return publishThunk({event, relays}) 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, getAddress,
isShareableRelayUrl, isShareableRelayUrl,
getRelaysFromList, getRelaysFromList,
sortEventsDesc,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util" import type {TrustedEvent, Filter, List} from "@welshman/util"
import {load, request} from "@welshman/net" import {load, request} from "@welshman/net"
@@ -48,11 +47,11 @@ export const makeFeed = ({
onForwardExhausted?: () => void onForwardExhausted?: () => void
at?: number at?: number
}) => { }) => {
const interval = int(WEEK)
const controller = new AbortController() const controller = new AbortController()
const events = writable<TrustedEvent[]>([]) const events = writable<TrustedEvent[]>([])
let interval = int(WEEK) let buffer: TrustedEvent[] = []
let buffer = sortEventsDesc(getEventsForUrl(url, filters))
let backwardWindow = [at - interval, at] let backwardWindow = [at - interval, at]
let forwardWindow = [at, at + interval] let forwardWindow = [at, at + interval]
@@ -112,20 +111,13 @@ export const makeFeed = ({
}), }),
] ]
const loadTimeframe = async (since: number, until: number) => { const loadTimeframe = (since: number, until: number) => {
const events = await request({ request({
relays: [url], relays: [url],
autoClose: true, autoClose: true,
signal: controller.signal, signal: controller.signal,
filters: filters.map(filter => ({...filter, since, until})), 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({ const backwardScroller = createScroller({
@@ -137,7 +129,7 @@ export const makeFeed = ({
backwardWindow = [since - interval, since] backwardWindow = [since - interval, since]
for (const event of buffer.splice(0, 30)) { for (const event of buffer.splice(0)) {
insertEvent(event) insertEvent(event)
} }
@@ -160,7 +152,7 @@ export const makeFeed = ({
forwardWindow = [until, until + interval] forwardWindow = [until, until + interval]
for (const event of buffer.splice(0, 30)) { for (const event of buffer.splice(0)) {
insertEvent(event) insertEvent(event)
} }
@@ -173,6 +165,10 @@ export const makeFeed = ({
}, },
}) })
for (const event of getEventsForUrl(url, filters)) {
insertEvent(event)
}
return { return {
events, events,
cleanup: () => { cleanup: () => {
+4 -52
View File
@@ -31,7 +31,6 @@ import {
groupBy, groupBy,
remove, remove,
simpleCache, simpleCache,
removeUndefined,
} from "@welshman/lib" } from "@welshman/lib"
import type {Override} from "@welshman/lib" import type {Override} from "@welshman/lib"
import type {RepositoryUpdate} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net"
@@ -100,7 +99,6 @@ import {
REPOST, REPOST,
GENERIC_REPOST, GENERIC_REPOST,
asDecryptedEvent, asDecryptedEvent,
getTagValue,
getGroupTags, getGroupTags,
getListTags, getListTags,
getPubkeyTagValues, getPubkeyTagValues,
@@ -113,7 +111,6 @@ import {
readRoomMeta, readRoomMeta,
makeRoomMeta, makeRoomMeta,
ManagementMethod, ManagementMethod,
sortEventsAsc,
sortEventsDesc, sortEventsDesc,
getAddress, getAddress,
Address, Address,
@@ -290,7 +287,7 @@ export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) =
derived( derived(
[deriveRelay(url), deriveEventsForUrl(url, filters)], [deriveRelay(url), deriveEventsForUrl(url, filters)],
([relay, events]) => events, ([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) // filter(spec({pubkey: relay.self}), events)
) )
@@ -431,11 +428,10 @@ export type PushSubscription = {
export type PushState = { export type PushState = {
token?: string token?: string
useFallback?: boolean
subscription?: PushSubscription subscription?: PushSubscription
} }
export const pushState = withGetter(writable<PushState>({})) export const notificationState = withGetter(writable<PushState>({}))
// Chats // Chats
@@ -669,7 +665,7 @@ export const deriveRoom = call(() => {
return (url: string, h: string) => return (url: string, h: string) =>
derived( derived(
_deriveRoom(makeRoomId(url, h)), _deriveRoom(makeRoomId(url, h)),
room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room, room => room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})},
) )
}) })
@@ -872,7 +868,7 @@ export const deriveRoomMembers = (url: string, h: string) => {
const members = new Set<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) const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) { if (event.kind === ROOM_ADD_MEMBER) {
@@ -906,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 // User membership status
export enum MembershipStatus { export enum MembershipStatus {
-41
View File
@@ -1,5 +1,4 @@
import {call} from "@welshman/lib" import {call} from "@welshman/lib"
import {SecureStorage} from "@aparajita/capacitor-secure-storage"
import {Preferences} from "@capacitor/preferences" import {Preferences} from "@capacitor/preferences"
import {IDB} from "@lib/indexeddb" import {IDB} from "@lib/indexeddb"
@@ -31,46 +30,6 @@ export const kv = call(() => {
return {get, set, clear} 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({ export const db = new IDB({
name: "flotilla-9gl", name: "flotilla-9gl",
version: 1, version: 1,
+59 -53
View File
@@ -1,7 +1,7 @@
import {page} from "$app/stores" import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store" import type {Unsubscriber} from "svelte/store"
import {derived, get} 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 { import {
getListTags, getListTags,
getRelayTagValues, getRelayTagValues,
@@ -18,9 +18,8 @@ import {
RELAY_REMOVE_MEMBER, RELAY_REMOVE_MEMBER,
isSignedEvent, isSignedEvent,
unionFilters, unionFilters,
getTagValue,
} from "@welshman/util" } 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 {request, requestOne, Difference, DifferenceEvent} from "@welshman/net"
import { import {
pubkey, pubkey,
@@ -64,24 +63,12 @@ type SyncOpts = {
url: string url: string
signal: AbortSignal signal: AbortSignal
filters: Filter[] filters: Filter[]
onEvent?: (event: TrustedEvent) => void
} }
const pullOneWithFallback = async ( const pullOneWithFallback = async (url: string, filter: Filter, signal: AbortSignal) => {
url: string,
filter: Filter,
signal: AbortSignal,
onEvent?: (event: TrustedEvent) => void,
) => {
const cachedEvents = repository.query([filter]).filter(isSignedEvent) const cachedEvents = repository.query([filter]).filter(isSignedEvent)
const since = last(cachedEvents.slice(10))?.created_at || 0 const since = last(cachedEvents.slice(10))?.created_at || 0
if (onEvent) {
for (const event of cachedEvents) {
onEvent(event)
}
}
const shouldFallback = const shouldFallback =
!hasNegentropy(url) || !hasNegentropy(url) ||
(await new Promise(resolve => { (await new Promise(resolve => {
@@ -93,7 +80,7 @@ const pullOneWithFallback = async (
diff.on(DifferenceEvent.Close, () => { diff.on(DifferenceEvent.Close, () => {
for (const ids of chunk(100, Array.from(diff.need))) { 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) resolve(false)
@@ -101,29 +88,29 @@ const pullOneWithFallback = async (
})) }))
if (shouldFallback && !signal.aborted) { 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) await loadRelay(url)
if (signal.aborted) return if (signal.aborted) return
for (const filter of filters) { 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] 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) => { const pullAndListen = ({url, filters, signal}: SyncOpts) => {
pullWithFallback(options) pullWithFallback({url, signal, filters})
listen(options) listen({url, signal, filters})
} }
// Relays // Relays
@@ -268,47 +255,59 @@ const syncUserData = () => {
// Spaces // Spaces
const syncSpace = (url: string, rooms: string[]) => { const syncSpace = (url: string, rooms: string[]) => {
const since = ago(WEEK)
const seen = new Set<string>()
const controller = new AbortController() const controller = new AbortController()
const pullRoomContent = (room: string) => { // Relay-level kinds don't need #h tags
if (!seen.has(room)) { pullAndListen({
seen.add(room) url,
signal: controller.signal,
filters: [{kinds: [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
})
// Room metadata uses #d tags, not #h, so no filtering needed
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS]}],
})
// 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({ pullAndListen({
url, url,
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{kinds: MESSAGE_KINDS, since, "#h": [room]}, {kinds: MESSAGE_KINDS, "#h": rooms, since},
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}), makeCommentFilter(CONTENT_KINDS, {"#h": rooms, since}),
], ],
}) })
}
}
for (const room of rooms) { listen({
pullRoomContent(room) url,
} signal: controller.signal,
filters: [{kinds: REACTION_KINDS, "#h": rooms}],
const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER] })
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS] } else {
const roomMemberKinds = [ROOM_DELETE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER] pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: roomKinds}],
})
pullAndListen({ pullAndListen({
url, url,
signal: controller.signal, signal: controller.signal,
filters: [ filters: [{kinds: MESSAGE_KINDS, since}, makeCommentFilter(CONTENT_KINDS, {since})],
{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({ listen({
@@ -316,6 +315,13 @@ const syncSpace = (url: string, rooms: string[]) => {
signal: controller.signal, signal: controller.signal,
filters: [{kinds: REACTION_KINDS}], filters: [{kinds: REACTION_KINDS}],
}) })
}
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [LIVEKIT_PARTICIPANTS]}],
})
return () => controller.abort() 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 {Push} from "@app/util/notifications"
import {deactivateCurrentPomadeSession} from "@app/util/pomade" import {deactivateCurrentPomadeSession} from "@app/util/pomade"
@@ -6,7 +6,6 @@ export const logout = async () => {
await deactivateCurrentPomadeSession() await deactivateCurrentPomadeSession()
await Push.disable() await Push.disable()
await kv.clear() await kv.clear()
await ss.clear()
await db.clear() await db.clear()
localStorage.clear() localStorage.clear()
+516 -26
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 {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 {synced, throttled, withGetter} from "@welshman/store"
import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app" import {load, LOCAL_RELAY_URL} from "@welshman/net"
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 { 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, MESSAGE_KINDS,
PUSH_BRIDGE,
PUSH_SERVER,
notificationSettings, notificationSettings,
notificationState,
chatsById, chatsById,
userSettingsValues,
userGroupList, userGroupList,
getSpaceUrlsFromGroupList, getSpaceUrlsFromGroupList,
getSpaceRoomsFromGroupList,
makeCommentFilter, makeCommentFilter,
userSpaceUrls,
shouldNotify,
hasNip29, hasNip29,
device,
} from "@app/core/state" } from "@app/core/state"
import {kv} from "@app/core/storage" import {kv} from "@app/core/storage"
import {goto} from "$app/navigation"
import {page} from "$app/stores" 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 // Checked state
@@ -45,9 +108,13 @@ export const syncChecked = () => {
.map((_, i, segments) => segments.slice(0, i + 1).join("/")) .map((_, i, segments) => segments.slice(0, i + 1).join("/"))
.slice(1) .slice(1)
// Set checked when we enter and when we leave a given page
return page.subscribe($page => { return page.subscribe($page => {
// Set checked when we leave a given page
checked.update($checked => { checked.update($checked => {
for (const path of getPaths($page.url.pathname)) {
$checked[path] = now()
}
for (const path of getPaths(prev)) { for (const path of getPaths(prev)) {
$checked[path] = now() $checked[path] = now()
} }
@@ -55,17 +122,6 @@ export const syncChecked = () => {
return $checked 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 prev = $page.url.pathname
}) })
} }
@@ -74,7 +130,7 @@ export const syncChecked = () => {
export const allNotifications = derived( export const allNotifications = derived(
throttled( throttled(
1000, 2000,
derived( derived(
[ [
pubkey, pubkey,
@@ -124,23 +180,27 @@ export const allNotifications = derived(
for (const url of getSpaceUrlsFromGroupList($userGroupList)) { for (const url of getSpaceUrlsFromGroupList($userGroupList)) {
const spacePath = makeSpacePath(url) 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))) { if (hasNip29($relaysByUrl.get(url))) {
for (const [h, [latestEvent]] of groupBy(e => getTagValue("h", e.tags), events)) { for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
if (h) {
const roomPath = makeRoomPath(url, h) const roomPath = makeRoomPath(url, h)
const latestEvent = find(e => e.tags.some(spec(["h", h])), eventsById.values())
if (hasNotification(roomPath, latestEvent)) { if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePath) paths.add(spacePath)
paths.add(roomPath) paths.add(roomPath)
} }
} }
}
} else { } else {
const messagesPath = makeSpaceChatPath(url) const messagesPath = makeSpaceChatPath(url)
if (hasNotification(messagesPath, first(events))) { if (hasNotification(messagesPath, first(eventsById.values()))) {
paths.add(spacePath) paths.add(spacePath)
paths.add(messagesPath) 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))) 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 // Badges
export const syncBadges = () => export const syncBadges = () =>
@@ -179,3 +284,388 @@ export const clearBadges = async () => {
// pass - firefox doesn't support this // 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 theme from "tailwindcss/defaultTheme"
import {get} from "svelte/store" import {get} from "svelte/store"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
@@ -6,7 +7,7 @@ import {page} from "$app/stores"
import {nthEq} from "@welshman/lib" import {nthEq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util" import {getAddress} from "@welshman/util"
import {tracker, userMessagingRelayList} from "@welshman/app" import {tracker} from "@welshman/app"
import {identity} from "@welshman/lib" import {identity} from "@welshman/lib"
import { import {
getTagValue, getTagValue,
@@ -16,32 +17,17 @@ import {
ZAP_GOAL, ZAP_GOAL,
EVENT_TIME, EVENT_TIME,
getPubkeyTagValues, getPubkeyTagValues,
getRelaysFromList,
} from "@welshman/util" } from "@welshman/util"
import {makeChatId, entityLink, encodeRelay, DM_KINDS, ROOM} from "@app/core/state" import {
import {pushModal} from "@app/util/modal" makeChatId,
entityLink,
decodeRelay,
encodeRelay,
userSpaceUrls,
DM_KINDS,
ROOM,
} from "@app/core/state"
import {lastPageBySpaceUrl, lastChatUrl} from "@app/util/history" 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)[]) => { export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) => {
let path = `/spaces/${encodeRelay(url)}` 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) => { export const makeMessagePath = (url: string, event: TrustedEvent) => {
const h = getTagValue(ROOM, event.tags) const h = getTagValue(ROOM, event.tags)
@@ -90,6 +84,26 @@ export const makeClassifiedPath = (url: string, address?: string) =>
export const makeCalendarPath = (url: string, address?: string) => export const makeCalendarPath = (url: string, address?: string) =>
makeSpacePath(url, "calendar", address) 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) => { export const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any const element = document.querySelector(`[data-event="${id}"]`) as any
+43 -74
View File
@@ -2,14 +2,7 @@
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
*/ */
import { import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client"
DisconnectReason,
Room as LiveKitRoom,
RoomEvent,
Track,
type AudioCaptureOptions,
type LocalParticipant,
} from "livekit-client"
import {derived, get, writable} from "svelte/store" import {derived, get, writable} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib" import {map, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
@@ -17,7 +10,7 @@ import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app" import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, whenAborted, whenTimeout} from "$lib/util" import {AbortError, whenAborted, whenTimeout} from "$lib/util"
import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state" import {deriveLatestEventForUrl} from "@app/core/state"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
export const LIVEKIT_PARTICIPANTS = 39004 export const LIVEKIT_PARTICIPANTS = 39004
@@ -27,7 +20,7 @@ export {checkRelayHasLivekit} from "$lib/livekit"
export type VoiceSession = { export type VoiceSession = {
url: string url: string
h: string h: string
room: LiveKitRoom room: Room
muted: boolean muted: boolean
} }
@@ -35,17 +28,13 @@ export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string} export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export enum VoiceState { export type VoiceState = "joining" | "connected" | "disconnected"
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined) 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()) export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
@@ -121,7 +110,10 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]), deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
], ],
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => { ([$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) { if (inCall) {
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity) const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
@@ -131,7 +123,7 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
if (!latestEvent) return [] if (!latestEvent) return []
const participants = removeUndefined( const participants = removeUndefined(
map( map(
(tag: string[]) => (tag[1] ? {pubkey: tag[1], identity: tag[1]} : undefined), (tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
getTags("participant", latestEvent.tags), getTags("participant", latestEvent.tags),
), ),
) )
@@ -140,41 +132,18 @@ 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) => { const onRoomDisconnected = (reason?: DisconnectReason) => {
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected) voiceState.set("disconnected")
const message = const message =
reason === DisconnectReason.JOIN_FAILURE reason === DisconnectReason.JOIN_FAILURE
? "Could not connect to voice room. Please try again." ? "Could not connect to voice room. Please try again."
: "Voice connection lost." : "Voice connection lost."
pushToast({theme: "error", message}) pushToast({theme: "error", message})
} }
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
} }
const onTrackSubscribed = (track: Track) => { const onTrackSubscribed = (track: Track) => {
@@ -214,19 +183,14 @@ export const cancelJoinVoiceRoom = () => {
joinAbortController?.abort() joinAbortController?.abort()
} }
export const joinVoiceRoom = async ( export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
url: string,
h: string,
startMuted = true,
preferredMicId?: string,
): Promise<void> => {
cancelJoinVoiceRoom() cancelJoinVoiceRoom()
const session = get(currentVoiceSession) const session = get(currentVoiceSession)
if (session) await leaveVoiceRoom() if (session) await leaveVoiceRoom()
currentVoiceRoom.set(get(deriveRoom(url, h))) currentVoiceRoom.set({url, h})
voiceState.set(VoiceState.Joining) voiceState.set("joining")
const controller = new AbortController() const controller = new AbortController()
joinAbortController = controller joinAbortController = controller
@@ -238,41 +202,47 @@ export const joinVoiceRoom = async (
if (signal.aborted) throw new AbortError() 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) room.on(RoomEvent.Disconnected, onRoomDisconnected)
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected) room.on(RoomEvent.ParticipantConnected, onParticipantConnected)
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed) room.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
try { try {
await Promise.race([ await Promise.race([
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}), room.connect(server_url, participant_token, {maxRetries: 0}),
whenTimeout(5_000, { whenTimeout(5_000, {
message: "Connection timed out. Please check your network and try again.", message: "Connection timed out. Please check your network and try again.",
}), }),
whenAborted(signal), whenAborted(signal),
]) ])
} catch (e) { } catch (e) {
liveKitRoom.disconnect() room.disconnect()
throw e throw e
} }
participantPubkeyMap.set(new Map()) participantPubkeyMap.set(new Map())
addParticipant(liveKitRoom.localParticipant.identity) addParticipant(room.localParticipant.identity)
for (const p of liveKitRoom.remoteParticipants.values()) { for (const p of room.remoteParticipants.values()) {
addParticipant(p.identity) 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}) currentVoiceSession.set({url, h, room, muted})
voiceState.set(VoiceState.Connected) voiceState.set("connected")
playJoinSound() playJoinSound()
} catch (e) { } catch (e) {
if (isActive()) voiceState.set(VoiceState.Disconnected) if (isActive()) voiceState.set("disconnected")
if (e instanceof AbortError) return if (e instanceof AbortError) return
throw e throw e
} finally { } finally {
@@ -287,17 +257,16 @@ export const leaveVoiceRoom = async () => {
const audio = new Audio("/leave-voice-room.mp3") const audio = new Audio("/leave-voice-room.mp3")
audio.play().catch(() => {}) audio.play().catch(() => {})
voiceState.set(VoiceState.Disconnected)
currentVoiceSession.set(undefined)
session.room.disconnect()
speakingParticipants.set([]) speakingParticipants.set([])
participantPubkeyMap.set(new Map()) participantPubkeyMap.set(new Map())
voiceState.set("disconnected")
session.room.disconnect()
currentVoiceSession.set(undefined)
} }
export const rejoinVoiceRoom = async (): Promise<void> => { export const rejoinVoiceRoom = () => {
const target = get(currentVoiceRoom) const target = get(currentVoiceRoom)
if (!target) return if (target) joinVoiceRoom(target.url, target.h)
return joinVoiceRoom(target.url, target.h)
} }
export const toggleMute = async () => { export const toggleMute = async () => {
+1 -4
View File
@@ -10,7 +10,6 @@
type Props = { type Props = {
onClose?: any onClose?: any
noEscape?: boolean
fullscreen?: boolean fullscreen?: boolean
children: { children: {
component: Component<any> 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( const wrapperClass = $derived(
cx("absolute inset-0 flex sm:relative pointer-events-none", { cx("absolute inset-0 flex sm:relative pointer-events-none", {
@@ -48,13 +47,11 @@
</button> </button>
<div class={wrapperClass}> <div class={wrapperClass}>
<div class={innerClass} transition:fly> <div class={innerClass} transition:fly>
{#if !noEscape}
<Button <Button
class="absolute -top-4 right-3 btn btn-circle btn-neutral btn-sm" class="absolute -top-4 right-3 btn btn-circle btn-neutral btn-sm"
onclick={clearModals}> onclick={clearModals}>
<Icon icon={Close} size={6} /> <Icon icon={Close} size={6} />
</Button> </Button>
{/if}
<children.component {...children.props} /> <children.component {...children.props} />
</div> </div>
</div> </div>
+14 -14
View File
@@ -15,20 +15,7 @@
const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus")) const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus"))
</script> </script>
{#if onclick} {#if href}
<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}
<a {href} class="relative z-nav-item flex h-14 w-14 items-center justify-center p-1"> <a {href} class="relative z-nav-item flex h-14 w-14 items-center justify-center p-1">
<div <div
class="aspect-square flex-grow cursor-pointer rounded-full {restProps.class} flex items-center justify-center transition-colors hover:bg-base-300" 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} {/if}
</div> </div>
</a> </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} {/if}
+1 -1
View File
@@ -12,7 +12,7 @@
<div <div
class={cx( 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, props.class,
)}> )}>
{@render children?.()} {@render children?.()}
+2 -4
View File
@@ -39,8 +39,7 @@
class:bg-base-100={active}> class:bg-base-100={active}>
{@render children?.()} {@render children?.()}
{#if notification} {#if notification}
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade> <div class="absolute right-2 top-5 h-2 w-2 rounded-full bg-primary" transition:fade></div>
</div>
{/if} {/if}
</a> </a>
{:else} {:else}
@@ -50,8 +49,7 @@
class:text-base-content={active} class:text-base-content={active}
class:bg-base-100={active}> class:bg-base-100={active}>
{#if notification} {#if notification}
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade> <div class="absolute right-2 top-5 h-2 w-2 rounded-full bg-primary" transition:fade></div>
</div>
{/if} {/if}
{@render children?.()} {@render children?.()}
</button> </button>
+11 -15
View File
@@ -27,8 +27,13 @@
import {setupHistory} from "@app/util/history" import {setupHistory} from "@app/util/history"
import {setupAnalytics} from "@app/util/analytics" import {setupAnalytics} from "@app/util/analytics"
import {authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy} from "@app/util/policies" import {authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy} from "@app/util/policies"
import {db, kv, ss} from "@app/core/storage" import {kv, db} from "@app/core/storage"
import {device, userSettingsValues, notificationSettings, pushState} from "@app/core/state" import {
device,
userSettingsValues,
notificationSettings,
notificationState,
} from "@app/core/state"
import {syncApplicationData} from "@app/core/sync" import {syncApplicationData} from "@app/core/sync"
import * as commands from "@app/core/commands" import * as commands from "@app/core/commands"
import * as requests from "@app/core/requests" import * as requests from "@app/core/requests"
@@ -36,7 +41,6 @@
import {theme} from "@app/util/theme" import {theme} from "@app/util/theme"
import {toast, pushToast} from "@app/util/toast" import {toast, pushToast} from "@app/util/toast"
import * as notifications from "@app/util/notifications" import * as notifications from "@app/util/notifications"
import {onPushNotificationAction} from "@app/util/push/adapters/common"
import * as storage from "@app/util/storage" import * as storage from "@app/util/storage"
import {syncKeyboard} from "@app/util/keyboard" import {syncKeyboard} from "@app/util/keyboard"
import {getPageTitle} from "@app/util/title" import {getPageTitle} from "@app/util/title"
@@ -67,16 +71,8 @@
}) })
// Listen for deep link events // Listen for deep link events
App.addListener("appUrlOpen", async (event: URLOpenListenerEvent) => { App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => {
const url = new URL(event.url) 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}` const target = `${url.pathname}${url.search}${url.hash}`
goto(target, {replaceState: false, noScroll: false}) goto(target, {replaceState: false, noScroll: false})
}) })
@@ -96,7 +92,7 @@
const unsubscribe = call(async () => { const unsubscribe = call(async () => {
const unsubscribers: Unsubscriber[] = [] const unsubscribers: Unsubscriber[] = []
// Sync stuff to storage // Sync stuff to localstorage
await Promise.all([ await Promise.all([
sync({ sync({
key: "device", key: "device",
@@ -111,7 +107,7 @@
sync({ sync({
key: "sessions", key: "sessions",
store: sessions, store: sessions,
storage: ss, storage: kv,
}), }),
sync({ sync({
key: "shouldUnwrap", key: "shouldUnwrap",
@@ -125,7 +121,7 @@
}), }),
sync({ sync({
key: "notificationState", key: "notificationState",
store: pushState, store: notificationState,
storage: kv, storage: kv,
}), }),
]) ])
+19 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import {fly} from "@lib/transition"
import UserCircle from "@assets/icons/user-circle.svg?dataurl" import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import Wallet from "@assets/icons/wallet.svg?dataurl" import Wallet from "@assets/icons/wallet.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl" import Server from "@assets/icons/server.svg?dataurl"
@@ -35,35 +36,51 @@
<SecondaryNavItem class="w-full !justify-between"> <SecondaryNavItem class="w-full !justify-between">
<strong class="ellipsize flex items-center gap-3"> Your Settings </strong> <strong class="ellipsize flex items-center gap-3"> Your Settings </strong>
</SecondaryNavItem> </SecondaryNavItem>
<div in:fly|local>
<SecondaryNavItem href="/settings/profile"> <SecondaryNavItem href="/settings/profile">
<Icon icon={UserCircle} /> Profile <Icon icon={UserCircle} /> Profile
</SecondaryNavItem> </SecondaryNavItem>
</div>
<div in:fly|local={{delay: 50}}>
<SecondaryNavItem href="/settings/alerts"> <SecondaryNavItem href="/settings/alerts">
<Icon icon={Bell} /> Alerts <Icon icon={Bell} /> Alerts
</SecondaryNavItem> </SecondaryNavItem>
{#if Capacitor.getPlatform() !== "ios"} </div>
<div in:fly|local={{delay: 100}} class:hidden={Capacitor.getPlatform() === "ios"}>
<SecondaryNavItem href="/settings/wallet"> <SecondaryNavItem href="/settings/wallet">
<Icon icon={Wallet} /> Wallet <Icon icon={Wallet} /> Wallet
</SecondaryNavItem> </SecondaryNavItem>
{/if} </div>
<div in:fly|local={{delay: 150}}>
<SecondaryNavItem href="/settings/relays"> <SecondaryNavItem href="/settings/relays">
<Icon icon={Server} /> Relays <Icon icon={Server} /> Relays
</SecondaryNavItem> </SecondaryNavItem>
</div>
<div in:fly|local={{delay: 200}}>
<SecondaryNavItem href="/settings/content"> <SecondaryNavItem href="/settings/content">
<Icon icon={GalleryMinimalistic} /> Content <Icon icon={GalleryMinimalistic} /> Content
</SecondaryNavItem> </SecondaryNavItem>
</div>
<div in:fly|local={{delay: 250}}>
<SecondaryNavItem href="/settings/privacy"> <SecondaryNavItem href="/settings/privacy">
<Icon icon={Shield} /> Privacy <Icon icon={Shield} /> Privacy
</SecondaryNavItem> </SecondaryNavItem>
</div>
<div in:fly|local={{delay: 300}}>
<SecondaryNavItem onclick={toggleTheme}> <SecondaryNavItem onclick={toggleTheme}>
<Icon icon={Moon} /> Theme <Icon icon={Moon} /> Theme
</SecondaryNavItem> </SecondaryNavItem>
</div>
<div in:fly|local={{delay: 350}}>
<SecondaryNavItem href="/settings/about"> <SecondaryNavItem href="/settings/about">
<Icon icon={Code2} /> About <Icon icon={Code2} /> About
</SecondaryNavItem> </SecondaryNavItem>
</div>
<div in:fly|local={{delay: 400}}>
<SecondaryNavItem class="text-error hover:text-error" onclick={logout}> <SecondaryNavItem class="text-error hover:text-error" onclick={logout}>
<Icon icon={Exit} /> Log Out <Icon icon={Exit} /> Log Out
</SecondaryNavItem> </SecondaryNavItem>
</div>
</SecondaryNavSection> </SecondaryNavSection>
</SecondaryNav> </SecondaryNav>
+3 -5
View File
@@ -24,19 +24,17 @@
clearBadges() clearBadges()
} }
let permission = "granted"
if (settings.push) { if (settings.push) {
permission = await Push.request() const permissions = await Push.request()
if (!permission.startsWith("granted")) { if (permissions !== "granted") {
await sleep(300) await sleep(300)
settings.push = false settings.push = false
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: `Failed to request notification permissions (${permission}).`, message: `Failed to request notification permissions (${permissions}).`,
}) })
} }
} }
+103 -5
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {derived} from "svelte/store"
import {shuffle, partition, ifLet} from "@welshman/lib"
import { import {
pubkey, pubkey,
getRelayLists, getRelayLists,
@@ -13,6 +15,10 @@
addSearchRelay, addSearchRelay,
removeSearchRelay, removeSearchRelay,
getRelay, getRelay,
setWriteRelays,
setReadRelays,
setSearchRelays,
setMessagingRelays,
} from "@welshman/app" } from "@welshman/app"
import {RelayMode} from "@welshman/util" import {RelayMode} from "@welshman/util"
import Plane from "@assets/icons/plane.svg?dataurl" import Plane from "@assets/icons/plane.svg?dataurl"
@@ -20,11 +26,15 @@
import Server from "@assets/icons/server.svg?dataurl" import Server from "@assets/icons/server.svg?dataurl"
import Mailbox from "@assets/icons/mailbox.svg?dataurl" import Mailbox from "@assets/icons/mailbox.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.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 ForbiddenCircle from "@assets/icons/forbidden-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import RelaySettingsItem from "@app/components/RelaySettingsItem.svelte" import RelaySettingsItem from "@app/components/RelaySettingsItem.svelte"
import RelaySettingsHealthChecks from "@app/components/RelaySettingsHealthChecks.svelte" import type {ActionItem} from "@app/components/RelaySettingsActionItem.svelte"
import {hasNip50} from "@app/core/state" 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" import {discoverRelays} from "@app/core/requests"
const readRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Read) const readRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Read)
@@ -37,20 +47,108 @@
const removeReadRelay = (url: string) => removeRelay(url, RelayMode.Read) const removeReadRelay = (url: string) => removeRelay(url, RelayMode.Read)
const addWriteRelay = (url: string) => addRelay(url, RelayMode.Write) const addWriteRelay = (url: string) => addRelay(url, RelayMode.Write)
const removeWriteRelay = (url: string) => removeRelay(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(() => { onMount(() => {
discoverRelays(getRelayLists()) discoverRelays(getRelayLists())
}) })
</script> </script>
<div class="content flex flex-col gap-4"> <div class="content">
<RelaySettingsHealthChecks />
<div class="card2 bg-alt flex flex-col gap-4 shadow-md"> <div class="card2 bg-alt flex flex-col gap-4 shadow-md">
<div class="flex justify-between">
<strong class="flex items-center gap-3 text-lg"> <strong class="flex items-center gap-3 text-lg">
<Icon icon={Server} /> <Icon icon={Server} />
Your Relays Your Relays
</strong> </strong>
<p class="mb-2"> {#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 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 network. We've set you up with some reasonable defaults, but if you're a power user, you can
customize your relay selections below. customize your relay selections below.
+15 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount, tick} from "svelte"
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {page} from "$app/stores" import {page} from "$app/stores"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
@@ -50,7 +50,7 @@
userSettingsValues, userSettingsValues,
} from "@app/core/state" } from "@app/core/state"
import VoiceWidget from "@app/components/VoiceWidget.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {VoiceState, voiceState} from "@app/voice" import {voiceState} from "@app/voice"
import {makeFeed} from "@app/core/requests" import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications" import {checked} from "@app/util/notifications"
@@ -176,14 +176,13 @@
const newMessages = document.getElementById("new-messages") const newMessages = document.getElementById("new-messages")
if (newMessagesSeen) { if (!newMessages || newMessagesSeen) {
showFixedNewMessages = false showFixedNewMessages = false
} else if (newMessages) { } else {
const {y} = newMessages.getBoundingClientRect() const {y} = newMessages.getBoundingClientRect()
if (y > 0 && y < 300) { if (y > 300) {
newMessagesSeen = true newMessagesSeen = true
showFixedNewMessages = false
} else { } else {
showFixedNewMessages = y < 0 showFixedNewMessages = y < 0
} }
@@ -301,7 +300,7 @@
elements.reverse() elements.reverse()
requestAnimationFrame(manageScrollPosition) tick().then(manageScrollPosition)
return elements return elements
}) })
@@ -364,8 +363,10 @@
</script> </script>
<SpaceBar> <SpaceBar>
{#snippet title()} {#snippet icon()}
<RoomImage {url} {h} /> <RoomImage {url} {h} />
{/snippet}
{#snippet title()}
<RoomName {url} {h} /> <RoomName {url} {h} />
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
@@ -382,6 +383,7 @@
<div class="py-20"> <div class="py-20">
<div class="card2 col-8 m-auto max-w-md items-center text-center"> <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> <p class="opacity-75">You aren't currently a member of this room.</p>
{#if !$room.isClosed}
{#if $membershipStatus === MembershipStatus.Pending} {#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}> <Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} /> <Icon icon={ClockCircle} />
@@ -397,6 +399,7 @@
Join Room Join Room
</Button> </Button>
{/if} {/if}
{/if}
</div> </div>
</div> </div>
{:else} {:else}
@@ -455,6 +458,7 @@
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted} {: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"> <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> <p class="opacity-75">Only members are allowed to post to this room.</p>
{#if !$room.isClosed}
{#if $membershipStatus === MembershipStatus.Pending} {#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}> <Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} /> <Icon icon={ClockCircle} />
@@ -470,6 +474,7 @@
Ask to Join Ask to Join
</Button> </Button>
{/if} {/if}
{/if}
</div> </div>
{:else} {:else}
<div> <div>
@@ -495,7 +500,7 @@
{/key} {/key}
{/if} {/if}
</div> </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"> <div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
<VoiceWidget /> <VoiceWidget />
</div> </div>
@@ -512,7 +517,7 @@
{#if showFixedNewMessages} {#if showFixedNewMessages}
<div class="relative z-popover flex justify-center"> <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}> <Button class="btn btn-primary btn-xs rounded-full" onclick={scrollToNewMessages}>
New Messages New Messages
</Button> </Button>
+6 -7
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount, tick} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
@@ -109,14 +109,13 @@
const newMessages = document.getElementById("new-messages") const newMessages = document.getElementById("new-messages")
if (newMessagesSeen) { if (!newMessages || newMessagesSeen) {
showFixedNewMessages = false showFixedNewMessages = false
} else if (newMessages) { } else {
const {y} = newMessages.getBoundingClientRect() const {y} = newMessages.getBoundingClientRect()
if (y > 0 && y < 300) { if (y > 300) {
newMessagesSeen = true newMessagesSeen = true
showFixedNewMessages = false
} else { } else {
showFixedNewMessages = y < 0 showFixedNewMessages = y < 0
} }
@@ -232,7 +231,7 @@
elements.reverse() elements.reverse()
requestAnimationFrame(manageScrollPosition) tick().then(manageScrollPosition)
return elements return elements
}) })
@@ -386,7 +385,7 @@
{#if showFixedNewMessages} {#if showFixedNewMessages}
<div class="relative z-popover flex justify-center"> <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}> <Button class="btn btn-primary btn-xs rounded-full" onclick={scrollToNewMessages}>
New Messages New Messages
</Button> </Button>
+1 -1
View File
@@ -9,7 +9,7 @@ config({path: ".env.template"})
export default { export default {
content: ["./src/**/*.{html,js,svelte,ts}"], content: ["./src/**/*.{html,js,svelte,ts}"],
darkMode: ["selector", '[data-theme="dark"]'], darkMode: ["selector", '[data-theme="dark"]'],
safelist: ["bg-success", "bg-warning", 'w-4 h-4'], safelist: ["bg-success", "bg-warning"],
theme: { theme: {
extend: {}, extend: {},
zIndex: { zIndex: {