From b6271d0e28a00ad902a9ac7afc4db88da4e67cea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 15:16:07 +0000 Subject: [PATCH 1/8] Add nsite (NIP-5A) support to Web Clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the Web Clients settings section with nsites — static websites published on Nostr per NIP-5A. Users can browse nsites discovered by querying relays globally for manifest events (kinds 15128/35128), install one (download its blobs from Blossom servers, verify each against its path sha256, and store under filesDir/nsites), and serve it through the existing localhost web-client mechanism. A daily update check (gated on the existing service timer, mirroring the weekly auto-backup) compares each installed manifest's aggregate hash and either auto-applies the update (per-nsite toggle) or posts a notification letting the user choose to update. - Settings: NsiteInfo model, nsites list, lastNsiteCheck + JSON helpers - LocalPreferences: persist nsites and last-check timestamp - NsiteManager: discovery, blob download/verify, install, update check - CustomWebSocketServer: File-based serveFromDir + nsite server start/stop - WebSocketServerService: daily check + update notification - ClipboardReceiver: notification action to apply an update - UI: BrowseNsitesScreen, NsiteRow, Web Clients section + nav wiring Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgPUkQoCoYH2Fz9bPxK1j7 --- .../citrine/server/CustomWebSocketServer.kt | 103 ++++ .../greenart7c3/citrine/server/Settings.kt | 36 ++ .../citrine/service/ClipboardReceiver.kt | 6 + .../citrine/service/LocalPreferences.kt | 12 + .../citrine/service/NsiteManager.kt | 539 ++++++++++++++++++ .../citrine/service/WebSocketServerService.kt | 67 +++ .../greenart7c3/citrine/ui/CitrineScaffold.kt | 11 +- .../citrine/ui/components/NsiteRow.kt | 85 +++ .../citrine/ui/navigation/Route.kt | 7 + .../citrine/ui/settings/BrowseNsitesScreen.kt | 119 ++++ .../ui/settings/WebClientsSettingsScreen.kt | 72 +++ app/src/main/res/values/strings.xml | 12 + 12 files changed, 1068 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt create mode 100644 app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt create mode 100644 app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt diff --git a/app/src/main/java/com/greenart7c3/citrine/server/CustomWebSocketServer.kt b/app/src/main/java/com/greenart7c3/citrine/server/CustomWebSocketServer.kt index 6284af01..4dfc81c5 100644 --- a/app/src/main/java/com/greenart7c3/citrine/server/CustomWebSocketServer.kt +++ b/app/src/main/java/com/greenart7c3/citrine/server/CustomWebSocketServer.kt @@ -82,6 +82,7 @@ import io.ktor.utils.io.copyTo import io.ktor.websocket.Frame import io.ktor.websocket.WebSocketDeflateExtension import io.ktor.websocket.readText +import java.io.File import java.net.ServerSocket import java.util.concurrent.ConcurrentHashMap import java.util.zip.Deflater @@ -910,6 +911,94 @@ class CustomWebSocketServer( ) } + /** + * Serves a file from an app-internal [rootDir] (used by installed nsites). Mirrors + * [serveIndex] but reads from a plain [File] tree instead of a SAF DocumentFile. Paths + * are canonicalized and rejected if they escape [rootDir] (path-traversal guard). Falls + * back to index.html for extension-less routes and to 404.html (with a 404 status) when + * nothing matches, per NIP-5A. + */ + private suspend fun serveFromDir( + call: ApplicationCall, + rootDir: File, + ) { + val requestedPath = call.request.uri.substringBefore('?').trimStart('/') + val rootCanonical = rootDir.canonicalFile + + fun resolveSafe(path: String): File? { + if (path.isEmpty()) return null + val candidate = File(rootCanonical, path).canonicalFile + return if (candidate.path == rootCanonical.path || candidate.path.startsWith(rootCanonical.path + File.separator)) { + candidate + } else { + null + } + } + + var target = resolveSafe(requestedPath)?.takeIf { it.isFile } + // SPA/index fallback for the root or extension-less routes. + if (target == null && (requestedPath.isEmpty() || !requestedPath.substringAfterLast('/').contains('.'))) { + target = resolveSafe("index.html")?.takeIf { it.isFile } + } + // 404 fallback. + if (target == null) { + target = resolveSafe("404.html")?.takeIf { it.isFile } + } + + if (target == null) { + return call.respond(HttpStatusCode.NotFound, null) + } + + val status = if (target.name == "404.html") HttpStatusCode.NotFound else HttpStatusCode.OK + call.respondOutputStream( + contentType = ContentType.defaultForFilePath(target.name), + status = status, + ) { + target.inputStream().use { it.copyTo(this) } + } + } + + fun startNsiteServerFor( + clientName: String, + rootDir: File, + ): WebClientServer { + val port = randomFreePort() + + val server = embeddedServer( + CIO, + host = "127.0.0.1", + port = port, + ) { + routing { + get("{...}") { + serveFromDir(call, rootDir) + } + } + }.start(wait = false) + + return WebClientServer( + name = clientName, + rootUri = rootDir.toURI().toString().toUri(), + port = port, + server = server, + ) + } + + /** Hot-adds (or replaces) a running server for an installed nsite without restarting the relay. */ + fun startNsiteServer(nsite: NsiteInfo) { + val dir = File(Citrine.instance.filesDir, "nsites/${nsite.folderName}") + if (!dir.isDirectory) return + val key = "/${nsite.folderName}" + webClientServers.remove(key)?.server?.stop() + webClientServers[key] = startNsiteServerFor(nsite.folderName, dir) + Log.d(Citrine.TAG, "Started nsite '${nsite.folderName}'") + } + + /** Stops and removes a running nsite server (used on delete). */ + fun stopNsiteServer(folderName: String) { + webClientServers.remove("/$folderName")?.server?.stop() + } + val proxyClient = HttpClient(io.ktor.client.engine.cio.CIO) { expectSuccess = false } @@ -992,6 +1081,20 @@ class CustomWebSocketServer( "Started web client '$name' on port ${clientServer.port}", ) } + + Settings.nsites.forEach { nsite -> + val dir = File(Citrine.instance.filesDir, "nsites/${nsite.folderName}") + if (dir.isDirectory) { + val key = "/${nsite.folderName}" + val clientServer = startNsiteServerFor(nsite.folderName, dir) + webClientServers[key] = clientServer + + Log.d( + Citrine.TAG, + "Started nsite '${nsite.folderName}' on port ${clientServer.port}", + ) + } + } } @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) diff --git a/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt b/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt index 1ebc9802..8e8d7c20 100644 --- a/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt +++ b/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt @@ -47,6 +47,14 @@ object Settings { var onionHostname = "" var webClients = mutableMapOf() + // Installed nsites (NIP-5A static websites). Downloaded to filesDir/nsites/ + // and served through the same localhost web-client mechanism as [webClients]. + var nsites: MutableList = mutableListOf() + + // Epoch seconds of the last daily nsite update check. Gated like [lastBackup] so the + // 100s service timer only runs the check roughly once per day. + var lastNsiteCheck: Long = 0L + var relayAggregatorEnabled = false var aggregatorPubkey = "" var relayAggregatorKinds: Set = setOf(0, 1, 3, 5, 6, 7, 1111, 10000, 10002, 30023) @@ -115,6 +123,8 @@ object Settings { useTor = false onionHostname = "" webClients = mutableMapOf() + nsites = mutableListOf() + lastNsiteCheck = 0L relayAggregatorEnabled = false aggregatorPubkey = "" relayAggregatorKinds = setOf(0, 1, 3, 5, 6, 7, 1111, 10000, 10002, 30023) @@ -133,8 +143,34 @@ object Settings { fun webClientFromJson(json: String): MutableMap = JacksonMapper.mapper.readValue>(json) fun webClientsToJson(): String = JacksonMapper.mapper.writeValueAsString(webClients) + + fun nsitesFromJson(json: String): MutableList = JacksonMapper.mapper.readValue>(json) + + fun nsitesToJson(): String = JacksonMapper.mapper.writeValueAsString(nsites) } +/** + * An installed nsite (NIP-5A static website). + * + * [address] is the canonical identifier: "15128:" for a root site or + * "35128::" for a named site. [folderName] is a filesystem-safe slug + * (prefixed with "nsite_") used both as the on-disk directory under filesDir/nsites and + * as the ".localhost" subdomain the relay serves it from. [aggregateHash] is + * the last-applied ["x", , "aggregate"] value, compared against a freshly fetched + * manifest to detect updates. + */ +data class NsiteInfo( + val address: String = "", + val pubkey: String = "", + val kind: Int = 0, + val dTag: String = "", + val displayName: String = "", + val folderName: String = "", + val aggregateHash: String = "", + val autoUpdate: Boolean = false, + val lastChecked: Long = 0L, +) + enum class OlderThan { DAY, WEEK, diff --git a/app/src/main/java/com/greenart7c3/citrine/service/ClipboardReceiver.kt b/app/src/main/java/com/greenart7c3/citrine/service/ClipboardReceiver.kt index 196458ae..c31e25d0 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/ClipboardReceiver.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/ClipboardReceiver.kt @@ -28,6 +28,12 @@ class ClipboardReceiver : BroadcastReceiver() { Citrine.instance.applicationScope.launch { Citrine.instance.client.disconnect() } + } else if (intent != null && intent.hasExtra("nsite_update")) { + val address = intent.getStringExtra("nsite_update") ?: return + NotificationManagerCompat.from(context).cancel(NSITE_UPDATE_NOTIFICATION_ID) + Citrine.instance.applicationScope.launch { + NsiteManager.applyUpdateByAddress(address) + } } } } diff --git a/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt index 1585c1e1..2ff91441 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt @@ -34,6 +34,8 @@ object PrefKeys { const val ONION_HOSTNAME = "onion_hostname" const val WEB_CLIENTS = "web_clients" + const val NSITES = "nsites" + const val LAST_NSITE_CHECK = "last_nsite_check" const val RELAY_AGGREGATOR_ENABLED = "relay_aggregator_enabled" const val AGGREGATOR_PUBKEY = "aggregator_pubkey" @@ -97,6 +99,12 @@ object LocalPreferences { } else { remove(PrefKeys.WEB_CLIENTS) } + if (settings.nsites.isNotEmpty()) { + putString(PrefKeys.NSITES, Settings.nsitesToJson()) + } else { + remove(PrefKeys.NSITES) + } + putLong(PrefKeys.LAST_NSITE_CHECK, settings.lastNsiteCheck) putBoolean(PrefKeys.RELAY_AGGREGATOR_ENABLED, settings.relayAggregatorEnabled) putString(PrefKeys.AGGREGATOR_PUBKEY, settings.aggregatorPubkey) @@ -155,6 +163,10 @@ object LocalPreferences { prefs.getString(PrefKeys.WEB_CLIENTS, null)?.let { Settings.webClients = Settings.webClientFromJson(it) } + prefs.getString(PrefKeys.NSITES, null)?.let { + Settings.nsites = Settings.nsitesFromJson(it) + } + Settings.lastNsiteCheck = prefs.getLong(PrefKeys.LAST_NSITE_CHECK, 0L) Settings.relayAggregatorEnabled = prefs.getBoolean(PrefKeys.RELAY_AGGREGATOR_ENABLED, false) Settings.aggregatorPubkey = prefs.getString(PrefKeys.AGGREGATOR_PUBKEY, "") ?: "" diff --git a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt new file mode 100644 index 00000000..1bc8460b --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt @@ -0,0 +1,539 @@ +package com.greenart7c3.citrine.service + +import com.greenart7c3.citrine.Citrine +import com.greenart7c3.citrine.logs.Log +import com.greenart7c3.citrine.okhttp.HttpClientManager +import com.greenart7c3.citrine.server.NsiteInfo +import com.greenart7c3.citrine.server.Settings +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils +import java.io.File +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.Request + +/** + * Discovers, downloads, and updates nsites (NIP-5A static websites). + * + * Manifests are kind 15128 (root site) or 35128 (named site, requires a `d` tag). Each + * manifest has `["path", "/url/path", ""]` tags mapping URLs to blob hashes and an + * `["x", "", "aggregate"]` tag used to detect content changes. Blobs are downloaded + * from Blossom servers (manifest `server` hints, else the author's kind-10063 list) and + * verified against their path hash before being written to filesDir/nsites/. + */ +object NsiteManager { + private const val TAG = "NsiteManager" + + const val KIND_ROOT_SITE = 15128 + const val KIND_NAMED_SITE = 35128 + private const val KIND_SERVER_LIST = 10063 + private val NSITE_KINDS = listOf(KIND_ROOT_SITE, KIND_NAMED_SITE) + + private const val BATCH_FETCH_TIMEOUT_MS = 15_000L + private const val DISCOVERY_TIMEOUT_MS = 20_000L + private const val DISCOVERY_LIMIT = 500 + + private val FALLBACK_RELAYS = setOf( + "wss://relay.damus.io/", + "wss://nos.lol/", + "wss://relay.primal.net/", + ) + + private val DEFAULT_BLOSSOM_SERVERS = listOf("https://blossom.primal.net") + + private val installing = AtomicBoolean(false) + + sealed interface DiscoveryState { + data object Idle : DiscoveryState + data object Loading : DiscoveryState + data class Loaded(val nsites: List) : DiscoveryState + data class Error(val message: String) : DiscoveryState + } + + data class DiscoveredNsite( + val address: String, + val pubkey: String, + val kind: Int, + val dTag: String, + val displayName: String, + val aggregateHash: String, + val serverHints: List, + val alreadyInstalled: Boolean, + ) + + data class NsiteUpdate( + val nsite: NsiteInfo, + val newManifest: Event, + val newAggregateHash: String, + ) + + private val _discoveryState = MutableStateFlow(DiscoveryState.Idle) + val discoveryState: StateFlow = _discoveryState.asStateFlow() + + // region relays + + private fun normalizeRemote(raw: String): NormalizedRelayUrl? { + val n = RelayUrlNormalizer.normalizeOrNull(raw) ?: return null + if (Citrine.instance.isPrivateIp(n.url)) return null + if (!Settings.useProxy && Citrine.instance.isOnionUrl(n.url)) return null + return NormalizedRelayUrl(url = n.url) + } + + private fun discoveryRelays(): List { + val configured = ( + Settings.relayAggregatorSourceRelays + + Settings.relayAggregatorIndexerRelays + + Settings.relayAggregatorExtraRelays + ) + val raw = configured.takeIf { it.isNotEmpty() } ?: FALLBACK_RELAYS + return raw.mapNotNull { normalizeRemote(it) }.ifEmpty { FALLBACK_RELAYS.mapNotNull { normalizeRemote(it) } } + } + + // endregion + + // region manifest parsing + + private fun dTagOf(ev: Event): String = ev.tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) ?: "" + + private fun addressOf(ev: Event): String = if (ev.kind == KIND_ROOT_SITE) { + "$KIND_ROOT_SITE:${ev.pubKey}" + } else { + "$KIND_NAMED_SITE:${ev.pubKey}:${dTagOf(ev)}" + } + + private fun aggregateHashOf(ev: Event): String = ev.tags.firstOrNull { it.size >= 3 && it[0] == "x" && it[2] == "aggregate" }?.get(1) ?: "" + + /** Returns (urlPath, sha256) for every `path` tag. */ + private fun pathTagsOf(ev: Event): List> = ev.tags.filter { it.size >= 3 && it[0] == "path" }.map { it[1] to it[2] } + + private fun serverHintsOf(ev: Event): List = ev.tags.filter { it.size >= 2 && it[0] == "server" }.map { it[1] } + + private fun displayNameOf(metadata: Event?, fallback: String): String { + if (metadata == null) return fallback + return try { + val node = JacksonMapper.mapper.readTree(metadata.content) + val name = node.get("display_name")?.asText()?.takeIf { it.isNotBlank() } + ?: node.get("name")?.asText()?.takeIf { it.isNotBlank() } + name ?: fallback + } catch (e: Exception) { + fallback + } + } + + /** "nsite" + first 16 hex chars of sha256(address): a deterministic, hostname-safe slug. */ + private fun folderNameFor(address: String): String = "nsite" + sha256Hex(address.toByteArray()).take(16) + + private fun shortPubkey(pubkey: String): String = if (pubkey.length > 12) "${pubkey.take(8)}…${pubkey.takeLast(4)}" else pubkey + + // endregion + + // region discovery + + suspend fun discover(): List { + _discoveryState.value = DiscoveryState.Loading + return try { + val relays = discoveryRelays() + if (relays.isEmpty()) { + _discoveryState.value = DiscoveryState.Loaded(emptyList()) + return emptyList() + } + val manifests = collectManifests(relays) + // Dedupe by address keeping the newest manifest. + val byAddress = HashMap() + for (ev in manifests) { + val addr = addressOf(ev) + val prev = byAddress[addr] + if (prev == null || prev.createdAt < ev.createdAt) byAddress[addr] = ev + } + + val names = fetchDisplayNames(byAddress.values.map { it.pubKey }.toSet(), relays) + val installedAddresses = Settings.nsites.map { it.address }.toSet() + + val result = byAddress.values.map { ev -> + val addr = addressOf(ev) + val dTag = if (ev.kind == KIND_NAMED_SITE) dTagOf(ev) else "" + val fallback = dTag.takeIf { it.isNotBlank() } ?: shortPubkey(ev.pubKey) + DiscoveredNsite( + address = addr, + pubkey = ev.pubKey, + kind = ev.kind, + dTag = dTag, + displayName = displayNameOf(names[ev.pubKey], fallback), + aggregateHash = aggregateHashOf(ev), + serverHints = serverHintsOf(ev), + alreadyInstalled = installedAddresses.contains(addr), + ) + }.sortedBy { it.displayName.lowercase() } + + _discoveryState.value = DiscoveryState.Loaded(result) + result + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "nsite discovery failed", e) + _discoveryState.value = DiscoveryState.Error(e.message ?: "Discovery failed") + emptyList() + } + } + + private suspend fun collectManifests(relays: List): List { + val client = Citrine.instance.client + if (!client.isActive()) client.connect() + + val results = ConcurrentHashMap() + val subId = newSubId() + val eoseSeen: MutableSet = ConcurrentHashMap.newKeySet() + val expected = relays.toSet() + val done = CompletableDeferred() + + val collector = object : RelayConnectionListener { + override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + when (msg) { + is EventMessage -> { + if (msg.subId != subId) return + val ev = msg.event + if (ev.kind == KIND_ROOT_SITE || ev.kind == KIND_NAMED_SITE) { + results[ev.id] = ev + } + } + is EoseMessage -> { + if (msg.subId != subId) return + markDone(relay) + } + else -> {} + } + } + + override fun onDisconnected(relay: IRelayClient) = markDone(relay) + + override fun onCannotConnect(relay: IRelayClient, errorMessage: String) = markDone(relay) + + private fun markDone(relay: IRelayClient) { + if (expected.contains(relay.url)) { + eoseSeen.add(relay.url) + if (eoseSeen.size >= expected.size) done.complete(Unit) + } + } + } + + client.addConnectionListener(collector) + try { + val filters = listOf(Filter(kinds = NSITE_KINDS, limit = DISCOVERY_LIMIT)) + runCatching { client.subscribe(subId, relays.associateWith { filters }) } + .onFailure { Log.e(TAG, "nsite discovery subscribe failed", it) } + withTimeoutOrNull(DISCOVERY_TIMEOUT_MS) { done.await() } + } finally { + runCatching { client.unsubscribe(subId) } + client.removeConnectionListener(collector) + } + return results.values.toList() + } + + private suspend fun fetchDisplayNames( + pubkeys: Set, + relays: List, + ): Map { + if (pubkeys.isEmpty()) return emptyMap() + val client = Citrine.instance.client + if (!client.isActive()) client.connect() + + val results = ConcurrentHashMap() + val subId = newSubId() + val eoseSeen: MutableSet = ConcurrentHashMap.newKeySet() + val expected = relays.toSet() + val done = CompletableDeferred() + + val collector = object : RelayConnectionListener { + override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + when (msg) { + is EventMessage -> { + if (msg.subId != subId) return + val ev = msg.event + if (ev.kind != 0) return + val prev = results[ev.pubKey] + if (prev == null || prev.createdAt < ev.createdAt) results[ev.pubKey] = ev + } + is EoseMessage -> { + if (msg.subId != subId) return + markDone(relay) + } + else -> {} + } + } + + override fun onDisconnected(relay: IRelayClient) = markDone(relay) + + override fun onCannotConnect(relay: IRelayClient, errorMessage: String) = markDone(relay) + + private fun markDone(relay: IRelayClient) { + if (expected.contains(relay.url)) { + eoseSeen.add(relay.url) + if (eoseSeen.size >= expected.size) done.complete(Unit) + } + } + } + + client.addConnectionListener(collector) + try { + val filters = listOf(Filter(kinds = listOf(0), authors = pubkeys.toList())) + runCatching { client.subscribe(subId, relays.associateWith { filters }) } + withTimeoutOrNull(BATCH_FETCH_TIMEOUT_MS) { done.await() } + } finally { + runCatching { client.unsubscribe(subId) } + client.removeConnectionListener(collector) + } + return results + } + + // endregion + + // region single fetches + + private suspend fun fetchManifest( + pubkey: String, + kind: Int, + dTag: String, + relays: List, + ): Event? { + if (relays.isEmpty()) return null + val client = Citrine.instance.client + val filters = listOf( + Filter( + kinds = listOf(kind), + authors = listOf(pubkey), + tags = if (dTag.isNotEmpty()) mapOf("d" to listOf(dTag)) else null, + limit = 1, + ), + ) + val subId = newSubId() + if (!client.isActive()) client.connect() + return try { + withTimeoutOrNull(BATCH_FETCH_TIMEOUT_MS) { + client.fetchFirst(subId, relays.associateWith { filters }) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "fetch manifest $kind:$pubkey failed", e) + null + } finally { + runCatching { client.unsubscribe(subId) } + } + } + + private suspend fun resolveServers(pubkey: String, manifest: Event, relays: List): List { + val fromManifest = serverHintsOf(manifest).mapNotNull { normalizeServerUrl(it) } + if (fromManifest.isNotEmpty()) return fromManifest.distinct() + + val serverList = fetchManifest(pubkey, KIND_SERVER_LIST, "", relays) + val fromList = serverList?.let { serverHintsOf(it).mapNotNull { url -> normalizeServerUrl(url) } } ?: emptyList() + return fromList.distinct().ifEmpty { DEFAULT_BLOSSOM_SERVERS } + } + + private fun normalizeServerUrl(raw: String): String? { + val trimmed = raw.trim().trimEnd('/') + if (!trimmed.startsWith("https://")) return null + return trimmed + } + + // endregion + + // region install / update + + /** Installs an nsite discovered via [discover]. Returns the stored [NsiteInfo] or a failure. */ + suspend fun install(discovered: DiscoveredNsite): Result = withContext(Dispatchers.IO) { + if (Citrine.isImportingEvents) return@withContext Result.failure(IllegalStateException("Busy importing events")) + if (!installing.compareAndSet(false, true)) return@withContext Result.failure(IllegalStateException("Another nsite operation is running")) + try { + val relays = discoveryRelays() + val manifest = fetchManifest(discovered.pubkey, discovered.kind, discovered.dTag, relays) + ?: return@withContext Result.failure(IllegalStateException("Could not fetch manifest")) + downloadAndStore(discovered.address, discovered.pubkey, discovered.kind, discovered.dTag, discovered.displayName, manifest, relays) + } finally { + installing.set(false) + } + } + + /** Re-downloads an nsite whose manifest changed. */ + suspend fun applyUpdate(update: NsiteUpdate): Result = withContext(Dispatchers.IO) { + if (Citrine.isImportingEvents) return@withContext Result.failure(IllegalStateException("Busy importing events")) + if (!installing.compareAndSet(false, true)) return@withContext Result.failure(IllegalStateException("Another nsite operation is running")) + try { + val nsite = update.nsite + val relays = discoveryRelays() + downloadAndStore(nsite.address, nsite.pubkey, nsite.kind, nsite.dTag, nsite.displayName, update.newManifest, relays, nsite.autoUpdate) + } finally { + installing.set(false) + } + } + + suspend fun applyUpdateByAddress(address: String): Result { + val nsite = Settings.nsites.firstOrNull { it.address == address } + ?: return Result.failure(IllegalStateException("nsite not installed")) + val relays = discoveryRelays() + val manifest = fetchManifest(nsite.pubkey, nsite.kind, nsite.dTag, relays) + ?: return Result.failure(IllegalStateException("Could not fetch manifest")) + return applyUpdate(NsiteUpdate(nsite, manifest, aggregateHashOf(manifest))) + } + + private suspend fun downloadAndStore( + address: String, + pubkey: String, + kind: Int, + dTag: String, + displayName: String, + manifest: Event, + relays: List, + autoUpdate: Boolean = false, + ): Result { + val paths = pathTagsOf(manifest) + if (paths.isEmpty()) return Result.failure(IllegalStateException("Manifest has no files")) + + val servers = resolveServers(pubkey, manifest, relays) + if (servers.isEmpty()) return Result.failure(IllegalStateException("No blossom servers found")) + + val folderName = folderNameFor(address) + val rootDir = File(Citrine.instance.filesDir, "nsites/$folderName") + val tmpDir = File(Citrine.instance.filesDir, "nsites/$folderName.tmp") + tmpDir.deleteRecursively() + tmpDir.mkdirs() + + try { + val tmpCanonical = tmpDir.canonicalFile + for ((urlPath, sha256) in paths) { + val outFile = File(tmpCanonical, urlPath.trimStart('/')).canonicalFile + if (outFile.path != tmpCanonical.path && !outFile.path.startsWith(tmpCanonical.path + File.separator)) { + return Result.failure(IllegalStateException("Illegal path in manifest: $urlPath")) + } + val bytes = downloadBlob(servers, sha256) + ?: return Result.failure(IllegalStateException("Failed to download $urlPath")) + outFile.parentFile?.mkdirs() + outFile.writeBytes(bytes) + } + } catch (e: Exception) { + tmpDir.deleteRecursively() + if (e is CancellationException) throw e + return Result.failure(e) + } + + // Swap tmp into place. + rootDir.deleteRecursively() + if (!tmpDir.renameTo(rootDir)) { + tmpDir.deleteRecursively() + return Result.failure(IllegalStateException("Failed to finalize nsite folder")) + } + + val info = NsiteInfo( + address = address, + pubkey = pubkey, + kind = kind, + dTag = dTag, + displayName = displayName, + folderName = folderName, + aggregateHash = aggregateHashOf(manifest), + autoUpdate = autoUpdate, + lastChecked = TimeUtils.now(), + ) + upsert(info) + CustomWebSocketService.server?.startNsiteServer(info) + return Result.success(info) + } + + /** Downloads a blob, trying each server in order and verifying its sha256. */ + private fun downloadBlob(servers: List, sha256: String): ByteArray? { + val client = HttpClientManager.getHttpClient(Settings.proxyAllUrls) + for (server in servers) { + try { + val request = Request.Builder().url("$server/$sha256").get().build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use + val bytes = response.body.bytes() + if (sha256Hex(bytes).equals(sha256, ignoreCase = true)) { + return bytes + } else { + Log.w(TAG, "sha256 mismatch for $sha256 from $server") + } + } + } catch (e: Exception) { + Log.w(TAG, "blob download failed from $server: ${e.message}") + } + } + return null + } + + // endregion + + // region update check + + /** Re-fetches every installed manifest and returns the ones whose aggregate hash changed. */ + suspend fun checkForUpdates(): List { + val relays = discoveryRelays() + val updates = mutableListOf() + val current = Settings.nsites.toList() + for (nsite in current) { + val manifest = fetchManifest(nsite.pubkey, nsite.kind, nsite.dTag, relays) ?: continue + val newHash = aggregateHashOf(manifest) + // Record the check time regardless of whether content changed. + updateLastChecked(nsite.address, TimeUtils.now()) + if (newHash.isNotBlank() && newHash != nsite.aggregateHash) { + updates.add(NsiteUpdate(nsite, manifest, newHash)) + } + } + return updates + } + + fun setAutoUpdate(address: String, enabled: Boolean) { + val idx = Settings.nsites.indexOfFirst { it.address == address } + if (idx < 0) return + Settings.nsites[idx] = Settings.nsites[idx].copy(autoUpdate = enabled) + LocalPreferences.saveSettingsToEncryptedStorage(Settings, Citrine.instance) + } + + fun delete(address: String) { + val nsite = Settings.nsites.firstOrNull { it.address == address } ?: return + Settings.nsites.removeAll { it.address == address } + LocalPreferences.saveSettingsToEncryptedStorage(Settings, Citrine.instance) + File(Citrine.instance.filesDir, "nsites/${nsite.folderName}").deleteRecursively() + CustomWebSocketService.server?.stopNsiteServer(nsite.folderName) + } + + private fun upsert(info: NsiteInfo) { + val idx = Settings.nsites.indexOfFirst { it.address == info.address } + if (idx >= 0) Settings.nsites[idx] = info else Settings.nsites.add(info) + LocalPreferences.saveSettingsToEncryptedStorage(Settings, Citrine.instance) + } + + private fun updateLastChecked(address: String, time: Long) { + val idx = Settings.nsites.indexOfFirst { it.address == address } + if (idx < 0) return + Settings.nsites[idx] = Settings.nsites[idx].copy(lastChecked = time) + LocalPreferences.saveSettingsToEncryptedStorage(Settings, Citrine.instance) + } + + // endregion + + private fun sha256Hex(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + return digest.joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/java/com/greenart7c3/citrine/service/WebSocketServerService.kt b/app/src/main/java/com/greenart7c3/citrine/service/WebSocketServerService.kt index 1af7a868..5800412f 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/WebSocketServerService.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/WebSocketServerService.kt @@ -45,6 +45,11 @@ import kotlinx.coroutines.withTimeoutOrNull private const val NOTIFICATION_THROTTLE_MS = 5_000L private const val ONE_WEEK_MILLIS = 7L * 24 * 60 * 60 * 1000 +private const val ONE_DAY_SECONDS = 24L * 60 * 60 + +// Notification id for "nsite update available" prompts. Ids 1/2/3 are already used by the +// foreground service, backup/download progress, and database-upgrade notifications. +const val NSITE_UPDATE_NOTIFICATION_ID = 4 class WebSocketServerService : Service() { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -171,6 +176,32 @@ class WebSocketServerService : Service() { Log.e(Citrine.TAG, "Error backing up database", e) } } + + // Daily nsite update check. Gated by lastNsiteCheck like the weekly backup, + // and persisted before the async work so the next 100s tick doesn't re-trigger. + if (!Citrine.isImportingEvents && Settings.nsites.isNotEmpty()) { + val oneDayAgo = TimeUtils.now() - ONE_DAY_SECONDS + if (Settings.lastNsiteCheck < oneDayAgo) { + Settings.lastNsiteCheck = TimeUtils.now() + LocalPreferences.saveSettingsToEncryptedStorage(Settings, Citrine.instance) + Citrine.instance.applicationScope.launch { + try { + val updates = NsiteManager.checkForUpdates() + updates.forEach { update -> + if (update.nsite.autoUpdate) { + NsiteManager.applyUpdate(update) + } else { + postNsiteUpdateNotification(update.nsite.address, update.nsite.displayName) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(Citrine.TAG, "Error checking nsite updates", e) + } + } + } + } } }, 0, @@ -406,6 +437,42 @@ class WebSocketServerService : Service() { ) } + private fun postNsiteUpdateNotification(address: String, displayName: String) { + val notificationManager = NotificationManagerCompat.from(Citrine.instance) + if (ActivityCompat.checkSelfPermission(Citrine.instance, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return + } + + val channel = NotificationChannelCompat.Builder( + "citrine", + NotificationManagerCompat.IMPORTANCE_DEFAULT, + ) + .setName("Citrine") + .build() + notificationManager.createNotificationChannel(channel) + + val updateIntent = Intent(this, ClipboardReceiver::class.java) + updateIntent.putExtra("nsite_update", address) + val updatePendingIntent = PendingIntent.getBroadcast( + this, + // Unique requestCode per address so concurrent nsite notifications don't collide. + address.hashCode(), + updateIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + + val notification = NotificationCompat.Builder(Citrine.instance, "citrine") + .setContentTitle(getString(R.string.app_name_release)) + .setContentText(getString(R.string.nsite_update_available_notification, displayName)) + .setSmallIcon(R.drawable.ic_notification) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .addAction(R.drawable.ic_launcher_background, getString(R.string.update_now), updatePendingIntent) + .build() + + notificationManager.notify(NSITE_UPDATE_NOTIFICATION_ID, notification) + } + private fun buildAggregatorSummary(status: AggregatorStatus): String { val phase = when (status.phase) { AggregatorPhase.IDLE -> getString(R.string.relay_aggregator_phase_idle) diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt b/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt index fa4f850a..012841cf 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt @@ -55,6 +55,7 @@ import com.greenart7c3.citrine.ui.navigation.Route import com.greenart7c3.citrine.ui.settings.AccessControlSettingsScreen import com.greenart7c3.citrine.ui.settings.AggregatorSettingsScreen import com.greenart7c3.citrine.ui.settings.BackupSettingsScreen +import com.greenart7c3.citrine.ui.settings.BrowseNsitesScreen import com.greenart7c3.citrine.ui.settings.NetworkSettingsScreen import com.greenart7c3.citrine.ui.settings.RelayInfoSettingsScreen import com.greenart7c3.citrine.ui.settings.RetentionSettingsScreen @@ -330,7 +331,15 @@ fun CitrineScaffold( BackupSettingsScreen(modifier = subScreenModifier, storageHelper = storageHelper, onApplyChanges = restartRelay) } composable(Route.WebClientsSettings.route) { - WebClientsSettingsScreen(modifier = subScreenModifier, storageHelper = storageHelper, onApplyChanges = restartRelay) + WebClientsSettingsScreen( + modifier = subScreenModifier, + storageHelper = storageHelper, + onApplyChanges = restartRelay, + onBrowseNsites = { navController.navigate(Route.BrowseNsites.route) }, + ) + } + composable(Route.BrowseNsites.route) { + BrowseNsitesScreen(modifier = subScreenModifier) } composable(Route.DatabaseInfo.route) { diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt b/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt new file mode 100644 index 00000000..19900ba5 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt @@ -0,0 +1,85 @@ +package com.greenart7c3.citrine.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.greenart7c3.citrine.R +import com.greenart7c3.citrine.server.NsiteInfo +import com.greenart7c3.citrine.server.Settings + +@Composable +fun NsiteRow( + nsite: NsiteInfo, + updateAvailable: Boolean, + onOpen: () -> Unit, + onToggleAutoUpdate: (Boolean) -> Unit, + onUpdate: () -> Unit, + onDelete: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(0.9f)) { + Text( + nsite.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "${nsite.folderName}.localhost:${Settings.port}", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = nsite.autoUpdate, onCheckedChange = onToggleAutoUpdate) + Text( + stringResource(R.string.auto_update), + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + if (updateAvailable) { + TextButton(onClick = onUpdate) { + Text(stringResource(R.string.update_now)) + } + } + } + } +} diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt b/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt index 75372bf6..df8c0cce 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt @@ -97,6 +97,12 @@ sealed class Route( icon = Icons.Outlined.Settings, selectedIcon = Icons.Default.Settings, ) + + data object BrowseNsites : Route( + route = "Settings/BrowseNsites", + icon = Icons.Outlined.Settings, + selectedIcon = Icons.Default.Settings, + ) } val navigationItems = listOf(Route.Home, Route.Settings) @@ -109,4 +115,5 @@ val settingsSubRoutes = setOf( Route.RetentionSettings.route, Route.BackupSettings.route, Route.WebClientsSettings.route, + Route.BrowseNsites.route, ) diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt new file mode 100644 index 00000000..d62322b5 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt @@ -0,0 +1,119 @@ +package com.greenart7c3.citrine.ui.settings + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.greenart7c3.citrine.R +import com.greenart7c3.citrine.service.NsiteManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun BrowseNsitesScreen( + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val state by NsiteManager.discoveryState.collectAsState() + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + NsiteManager.discover() + } + } + + Surface(modifier) { + when (val current = state) { + is NsiteManager.DiscoveryState.Loading, NsiteManager.DiscoveryState.Idle -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Text( + stringResource(R.string.discovering_nsites), + modifier = Modifier.padding(top = 16.dp), + ) + } + } + } + is NsiteManager.DiscoveryState.Error -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(current.message) + } + } + is NsiteManager.DiscoveryState.Loaded -> { + if (current.nsites.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.no_nsites_found)) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(current.nsites) { nsite -> + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !nsite.alreadyInstalled) { + Toast.makeText(context, context.getString(R.string.installing_nsite, nsite.displayName), Toast.LENGTH_SHORT).show() + scope.launch(Dispatchers.IO) { + val result = NsiteManager.install(nsite) + withContext(Dispatchers.Main) { + val message = if (result.isSuccess) { + context.getString(R.string.nsite_installed, nsite.displayName) + } else { + context.getString(R.string.nsite_install_failed, nsite.displayName) + } + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + // Refresh the list so the installed item is now flagged. + NsiteManager.discover() + } + } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + nsite.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + if (nsite.alreadyInstalled) { + stringResource(R.string.nsite_already_installed) + } else { + nsite.address + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + HorizontalDivider() + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt index 10eafa74..bfec0a61 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt @@ -2,11 +2,19 @@ package com.greenart7c3.citrine.ui.settings import android.content.Intent import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -17,12 +25,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.anggrayudi.storage.SimpleStorageHelper import com.greenart7c3.citrine.Citrine import com.greenart7c3.citrine.R import com.greenart7c3.citrine.server.Settings import com.greenart7c3.citrine.service.LocalPreferences +import com.greenart7c3.citrine.service.NsiteManager +import com.greenart7c3.citrine.ui.components.NsiteRow import com.greenart7c3.citrine.ui.components.WebAppInput import com.greenart7c3.citrine.ui.components.WebAppRow import kotlinx.coroutines.Dispatchers @@ -34,6 +45,7 @@ fun WebClientsSettingsScreen( modifier: Modifier = Modifier, storageHelper: SimpleStorageHelper, onApplyChanges: () -> Unit, + onBrowseNsites: () -> Unit, ) { var isLoading by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -42,6 +54,8 @@ fun WebClientsSettingsScreen( Surface(modifier) { var webPath by remember { mutableStateOf(TextFieldValue("")) } var webClients by remember { mutableStateOf>(Settings.webClients.toMap()) } + var nsites by remember { mutableStateOf(Settings.nsites.toList()) } + var updatesAvailable by remember { mutableStateOf>(emptySet()) } storageHelper.onFolderSelected = { _, folder -> val path = if (webPath.text.startsWith("/")) webPath.text else "/${webPath.text}" @@ -91,6 +105,64 @@ fun WebClientsSettingsScreen( item = item, ) } + + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + Text( + stringResource(R.string.nsites), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 4.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = onBrowseNsites) { + Text(stringResource(R.string.browse_nsites)) + } + Button( + onClick = { + scope.launch(Dispatchers.IO) { + val updates = NsiteManager.checkForUpdates() + updatesAvailable = updates.map { it.nsite.address }.toSet() + nsites = Settings.nsites.toList() + } + }, + ) { + Text(stringResource(R.string.check_for_updates)) + } + } + } + + items(nsites) { nsite -> + NsiteRow( + nsite = nsite, + updateAvailable = updatesAvailable.contains(nsite.address), + onOpen = { + val browserIntent = Intent( + Intent.ACTION_VIEW, + "http://${nsite.folderName}.localhost:${Settings.port}".toUri(), + ) + browserIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + Citrine.instance.startActivity(browserIntent) + }, + onToggleAutoUpdate = { enabled -> + NsiteManager.setAutoUpdate(nsite.address, enabled) + nsites = Settings.nsites.toList() + }, + onUpdate = { + scope.launch(Dispatchers.IO) { + NsiteManager.applyUpdateByAddress(nsite.address) + updatesAvailable = updatesAvailable - nsite.address + nsites = Settings.nsites.toList() + } + }, + onDelete = { + NsiteManager.delete(nsite.address) + nsites = Settings.nsites.toList() + }, + ) + } } SettingsApplyBar(enabled = !isLoading, onApply = applyChanges) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9596dac..3cf82801 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -170,6 +170,18 @@ Automatic database backups Web clients Host static web apps on the relay + Nsites + Browse nsites + Check for updates + Auto-update + Update now + Discovering nsites… + No nsites found + Installing %1$s… + Installed %1$s + Failed to install %1$s + Already installed + Update available for %1$s Reset all settings All settings will be restored to their default values. This action cannot be undone. Settings reset to defaults From 5aecb16a25569f1b950ef83805ea8e1cfb1dd4d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 17:44:31 +0000 Subject: [PATCH 2/8] Show the nsite's own title in the browser list The browser list showed the author's kind-0 profile name. NIP-5A manifests carry an optional `title` tag (the site's own name); prefer it as the primary label, falling back to the d tag, author name, then short pubkey. The author name is now shown as the secondary line. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgPUkQoCoYH2Fz9bPxK1j7 --- .../com/greenart7c3/citrine/service/NsiteManager.kt | 12 ++++++++++-- .../citrine/ui/settings/BrowseNsitesScreen.kt | 8 ++++---- app/src/main/res/values/strings.xml | 1 + 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt index 1bc8460b..82222224 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt @@ -76,6 +76,7 @@ object NsiteManager { val kind: Int, val dTag: String, val displayName: String, + val authorName: String, val aggregateHash: String, val serverHints: List, val alreadyInstalled: Boolean, @@ -123,6 +124,9 @@ object NsiteManager { private fun aggregateHashOf(ev: Event): String = ev.tags.firstOrNull { it.size >= 3 && it[0] == "x" && it[2] == "aggregate" }?.get(1) ?: "" + /** The nsite's own human-readable name from the manifest `title` tag, if present. */ + private fun titleOf(ev: Event): String = ev.tags.firstOrNull { it.size >= 2 && it[0] == "title" }?.get(1)?.takeIf { it.isNotBlank() } ?: "" + /** Returns (urlPath, sha256) for every `path` tag. */ private fun pathTagsOf(ev: Event): List> = ev.tags.filter { it.size >= 3 && it[0] == "path" }.map { it[1] to it[2] } @@ -172,13 +176,17 @@ object NsiteManager { val result = byAddress.values.map { ev -> val addr = addressOf(ev) val dTag = if (ev.kind == KIND_NAMED_SITE) dTagOf(ev) else "" - val fallback = dTag.takeIf { it.isNotBlank() } ?: shortPubkey(ev.pubKey) + val authorName = displayNameOf(names[ev.pubKey], "") + // Prefer the nsite's own title; fall back to the d tag, then the author's + // profile name, then a short pubkey. + val name = titleOf(ev).ifBlank { dTag.ifBlank { authorName.ifBlank { shortPubkey(ev.pubKey) } } } DiscoveredNsite( address = addr, pubkey = ev.pubKey, kind = ev.kind, dTag = dTag, - displayName = displayNameOf(names[ev.pubKey], fallback), + displayName = name, + authorName = authorName, aggregateHash = aggregateHashOf(ev), serverHints = serverHintsOf(ev), alreadyInstalled = installedAddresses.contains(addr), diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt index d62322b5..ec4e480e 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt @@ -99,10 +99,10 @@ fun BrowseNsitesScreen( style = MaterialTheme.typography.bodyLarge, ) Text( - if (nsite.alreadyInstalled) { - stringResource(R.string.nsite_already_installed) - } else { - nsite.address + when { + nsite.alreadyInstalled -> stringResource(R.string.nsite_already_installed) + nsite.authorName.isNotBlank() -> stringResource(R.string.nsite_by_author, nsite.authorName) + else -> nsite.address }, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3cf82801..55794100 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -181,6 +181,7 @@ Installed %1$s Failed to install %1$s Already installed + by %1$s Update available for %1$s Reset all settings All settings will be restored to their default values. This action cannot be undone. From 4321c01c38cb19a82c27c15cb5acf8e2a0c5c50c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 18:26:47 +0000 Subject: [PATCH 3/8] Show nsite website icons via Coil Add Coil 3 to load each nsite's icon. NIP-5A has no icon tag, so the icon is one of the manifest's path-mapped blobs (favicon.png, etc.); the browse list loads it from a Blossom URL and the installed list loads it from the already-downloaded file on disk. Coil's network fetcher is wired to the app's OkHttp client so icon requests honor the Tor/proxy fail-closed policy. Undecodable/missing icons fall back to a globe placeholder. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgPUkQoCoYH2Fz9bPxK1j7 --- app/build.gradle.kts | 2 + .../java/com/greenart7c3/citrine/Citrine.kt | 20 +++++- .../citrine/service/NsiteManager.kt | 42 +++++++++++++ .../citrine/ui/components/NsiteIcon.kt | 61 +++++++++++++++++++ .../citrine/ui/components/NsiteRow.kt | 5 +- .../citrine/ui/settings/BrowseNsitesScreen.kt | 40 ++++++------ gradle/libs.versions.toml | 3 + 7 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteIcon.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b45f7147..77d1f7cd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -173,4 +173,6 @@ dependencies { implementation(libs.kmp.tor.runtime.service.ui) implementation(libs.kmp.tor.resource.exec) implementation(libs.negentropy) + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) } diff --git a/app/src/main/java/com/greenart7c3/citrine/Citrine.kt b/app/src/main/java/com/greenart7c3/citrine/Citrine.kt index 0c8b6b74..601ae29d 100644 --- a/app/src/main/java/com/greenart7c3/citrine/Citrine.kt +++ b/app/src/main/java/com/greenart7c3/citrine/Citrine.kt @@ -7,6 +7,10 @@ import android.app.PendingIntent import android.content.Intent import android.content.IntentFilter import android.os.Build +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.network.okhttp.OkHttpNetworkFetcherFactory import com.greenart7c3.citrine.database.AppDatabase import com.greenart7c3.citrine.logs.Log import com.greenart7c3.citrine.okhttp.HttpClientManager @@ -31,8 +35,22 @@ import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -class Citrine : Application() { +class Citrine : + Application(), + SingletonImageLoader.Factory { val crashReportCache: CrashReportCache by lazy { CrashReportCache(this.applicationContext) } + + // Coil image loader whose network fetches route through the app's OkHttp client so nsite + // icon downloads honor the relay's Tor/proxy fail-closed policy instead of going direct. + override fun newImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) + .components { + add( + OkHttpNetworkFetcherFactory( + callFactory = { HttpClientManager.getHttpClient(Settings.proxyAllUrls) }, + ), + ) + } + .build() val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Log.e("AmberCoroutine", "Caught exception: ${throwable.message}", throwable) diff --git a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt index 82222224..10f93ee8 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt @@ -61,6 +61,18 @@ object NsiteManager { private val DEFAULT_BLOSSOM_SERVERS = listOf("https://blossom.primal.net") + // Common raster icon filenames, in priority order. Coil can't decode .ico/.svg, so those + // are intentionally excluded — a site with only those simply shows the placeholder. + private val ICON_NAMES = listOf( + "favicon.png", + "icon.png", + "apple-touch-icon.png", + "logo.png", + "icon-192.png", + "icon-512.png", + "favicon-32x32.png", + ) + private val installing = AtomicBoolean(false) sealed interface DiscoveryState { @@ -79,6 +91,7 @@ object NsiteManager { val authorName: String, val aggregateHash: String, val serverHints: List, + val iconUrl: String?, val alreadyInstalled: Boolean, ) @@ -132,6 +145,34 @@ object NsiteManager { private fun serverHintsOf(ev: Event): List = ev.tags.filter { it.size >= 2 && it[0] == "server" }.map { it[1] } + /** sha256 of the highest-priority decodable icon file mapped by a `path` tag, if any. */ + private fun iconHashOf(ev: Event): String? { + val paths = pathTagsOf(ev) + for (name in ICON_NAMES) { + val match = paths.firstOrNull { (urlPath, _) -> urlPath.substringAfterLast('/').lowercase() == name } + if (match != null) return match.second + } + return null + } + + /** Builds a Blossom URL for an icon, or null when there's no icon hash. */ + private fun iconUrlOf(ev: Event): String? { + val hash = iconHashOf(ev) ?: return null + val server = serverHintsOf(ev).mapNotNull { normalizeServerUrl(it) }.firstOrNull() + ?: DEFAULT_BLOSSOM_SERVERS.first() + return "$server/$hash" + } + + /** The on-disk icon file for an installed nsite, scanning the known icon filenames. */ + fun localIconFile(folderName: String): File? { + val dir = File(Citrine.instance.filesDir, "nsites/$folderName") + for (name in ICON_NAMES) { + val file = File(dir, name) + if (file.isFile) return file + } + return null + } + private fun displayNameOf(metadata: Event?, fallback: String): String { if (metadata == null) return fallback return try { @@ -189,6 +230,7 @@ object NsiteManager { authorName = authorName, aggregateHash = aggregateHashOf(ev), serverHints = serverHintsOf(ev), + iconUrl = iconUrlOf(ev), alreadyInstalled = installedAddresses.contains(addr), ) }.sortedBy { it.displayName.lowercase() } diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteIcon.kt b/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteIcon.kt new file mode 100644 index 00000000..b7fcdacf --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteIcon.kt @@ -0,0 +1,61 @@ +package com.greenart7c3.citrine.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil3.compose.SubcomposeAsyncImage +import coil3.request.ImageRequest + +/** + * Shows an nsite's icon (a Blossom URL or a local [java.io.File]) via Coil, falling back to a + * globe placeholder while loading or when no icon is available/decodable. + */ +@Composable +fun NsiteIcon( + model: Any?, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(8.dp) + Box( + modifier = modifier + .size(40.dp) + .clip(shape), + contentAlignment = Alignment.Center, + ) { + if (model == null) { + Placeholder() + } else { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(model) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(40.dp), + loading = { Placeholder() }, + error = { Placeholder() }, + ) + } + } +} + +@Composable +private fun Placeholder() { + Icon( + imageVector = Icons.Default.Public, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + ) +} diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt b/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt index 19900ba5..5c2d3292 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/components/NsiteRow.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -23,6 +24,7 @@ import androidx.compose.ui.unit.dp import com.greenart7c3.citrine.R import com.greenart7c3.citrine.server.NsiteInfo import com.greenart7c3.citrine.server.Settings +import com.greenart7c3.citrine.service.NsiteManager @Composable fun NsiteRow( @@ -41,7 +43,8 @@ fun NsiteRow( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(0.9f)) { + NsiteIcon(model = remember(nsite.folderName) { NsiteManager.localIconFile(nsite.folderName) }) + Column(modifier = Modifier.weight(0.9f).padding(start = 12.dp)) { Text( nsite.displayName, maxLines = 1, diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt index ec4e480e..7c12008c 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt @@ -4,6 +4,7 @@ import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -27,6 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.greenart7c3.citrine.R import com.greenart7c3.citrine.service.NsiteManager +import com.greenart7c3.citrine.ui.components.NsiteIcon import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -71,7 +73,7 @@ fun BrowseNsitesScreen( } else { LazyColumn(modifier = Modifier.fillMaxSize()) { items(current.nsites) { nsite -> - Column( + Row( modifier = Modifier .fillMaxWidth() .clickable(enabled = !nsite.alreadyInstalled) { @@ -91,23 +93,27 @@ fun BrowseNsitesScreen( } } .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - nsite.displayName, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyLarge, - ) - Text( - when { - nsite.alreadyInstalled -> stringResource(R.string.nsite_already_installed) - nsite.authorName.isNotBlank() -> stringResource(R.string.nsite_by_author, nsite.authorName) - else -> nsite.address - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - ) + NsiteIcon(model = nsite.iconUrl) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text( + nsite.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + when { + nsite.alreadyInstalled -> stringResource(R.string.nsite_already_installed) + nsite.authorName.isNotBlank() -> stringResource(R.string.nsite_by_author, nsite.authorName) + else -> nsite.address + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } } HorizontalDivider() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc7a0d94..0f98e0c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ pagingCommon = "3.5.0" kmpTor = "2.6.0" kmpTorResource = "409.5.0" negentropy = "1.0.1" +coil = "3.1.0" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -72,6 +73,8 @@ kmp-tor-runtime-service = { module = "io.matthewnelson.kmp-tor:runtime-service", kmp-tor-runtime-service-ui = { module = "io.matthewnelson.kmp-tor:runtime-service-ui", version.ref = "kmpTor" } kmp-tor-resource-exec = { module = "io.matthewnelson.kmp-tor:resource-exec-tor", version.ref = "kmpTorResource" } negentropy = { module = "com.vitorpamplona.negentropy:kmp-negentropy-android", version.ref = "negentropy" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } [plugins] jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } From c7293e189692f080ac1796897636ac2a0399ed94 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 18:28:48 +0000 Subject: [PATCH 4/8] Sort browse list by last update and add a search bar Order discovered nsites by their manifest's most recent update (newest first) instead of alphabetically, and add a search field that filters the list by name, author, or address. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgPUkQoCoYH2Fz9bPxK1j7 --- .../citrine/service/NsiteManager.kt | 4 +- .../citrine/ui/settings/BrowseNsitesScreen.kt | 115 ++++++++++++------ app/src/main/res/values/strings.xml | 1 + 3 files changed, 80 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt index 10f93ee8..09281a3f 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt @@ -92,6 +92,7 @@ object NsiteManager { val aggregateHash: String, val serverHints: List, val iconUrl: String?, + val lastUpdated: Long, val alreadyInstalled: Boolean, ) @@ -231,9 +232,10 @@ object NsiteManager { aggregateHash = aggregateHashOf(ev), serverHints = serverHintsOf(ev), iconUrl = iconUrlOf(ev), + lastUpdated = ev.createdAt, alreadyInstalled = installedAddresses.contains(addr), ) - }.sortedBy { it.displayName.lowercase() } + }.sortedByDescending { it.lastUpdated } _discoveryState.value = DiscoveryState.Loaded(result) result diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt index 7c12008c..22926398 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt @@ -10,20 +10,28 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.greenart7c3.citrine.R @@ -40,6 +48,7 @@ fun BrowseNsitesScreen( val scope = rememberCoroutineScope() val context = LocalContext.current val state by NsiteManager.discoveryState.collectAsState() + var searchQuery by remember { mutableStateOf(TextFieldValue("")) } LaunchedEffect(Unit) { withContext(Dispatchers.IO) { @@ -71,51 +80,79 @@ fun BrowseNsitesScreen( Text(stringResource(R.string.no_nsites_found)) } } else { - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(current.nsites) { nsite -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !nsite.alreadyInstalled) { - Toast.makeText(context, context.getString(R.string.installing_nsite, nsite.displayName), Toast.LENGTH_SHORT).show() - scope.launch(Dispatchers.IO) { - val result = NsiteManager.install(nsite) - withContext(Dispatchers.Main) { - val message = if (result.isSuccess) { - context.getString(R.string.nsite_installed, nsite.displayName) - } else { - context.getString(R.string.nsite_install_failed, nsite.displayName) + val query = searchQuery.text.trim() + val filtered = if (query.isEmpty()) { + current.nsites + } else { + current.nsites.filter { + it.displayName.contains(query, ignoreCase = true) || + it.authorName.contains(query, ignoreCase = true) || + it.address.contains(query, ignoreCase = true) + } + } + Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + placeholder = { Text(stringResource(R.string.search_nsites)) }, + ) + if (filtered.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.no_nsites_found)) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(filtered) { nsite -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !nsite.alreadyInstalled) { + Toast.makeText(context, context.getString(R.string.installing_nsite, nsite.displayName), Toast.LENGTH_SHORT).show() + scope.launch(Dispatchers.IO) { + val result = NsiteManager.install(nsite) + withContext(Dispatchers.Main) { + val message = if (result.isSuccess) { + context.getString(R.string.nsite_installed, nsite.displayName) + } else { + context.getString(R.string.nsite_install_failed, nsite.displayName) + } + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + // Refresh the list so the installed item is now flagged. + NsiteManager.discover() } - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } - // Refresh the list so the installed item is now flagged. - NsiteManager.discover() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NsiteIcon(model = nsite.iconUrl) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text( + nsite.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + when { + nsite.alreadyInstalled -> stringResource(R.string.nsite_already_installed) + nsite.authorName.isNotBlank() -> stringResource(R.string.nsite_by_author, nsite.authorName) + else -> nsite.address + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) } } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - NsiteIcon(model = nsite.iconUrl) - Column(modifier = Modifier.padding(start = 12.dp)) { - Text( - nsite.displayName, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyLarge, - ) - Text( - when { - nsite.alreadyInstalled -> stringResource(R.string.nsite_already_installed) - nsite.authorName.isNotBlank() -> stringResource(R.string.nsite_by_author, nsite.authorName) - else -> nsite.address - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - ) + HorizontalDivider() } } - HorizontalDivider() } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 55794100..89bbbb97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -177,6 +177,7 @@ Update now Discovering nsites… No nsites found + Search nsites Installing %1$s… Installed %1$s Failed to install %1$s From 56c1261e0b8efb085f41f0d481346f9c284a554b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 18:53:16 +0000 Subject: [PATCH 5/8] Show install progress in the nsite browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread a progress callback through NsiteManager.install/downloadAndStore reporting downloaded/total blobs. While installing, the browse row shows a determinate spinner in place of the icon and a "Installing… x/y files" subtitle, and other rows are disabled until it finishes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgPUkQoCoYH2Fz9bPxK1j7 --- .../citrine/service/NsiteManager.kt | 12 +++++-- .../citrine/ui/settings/BrowseNsitesScreen.kt | 36 ++++++++++++++++--- app/src/main/res/values/strings.xml | 1 + 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt index 09281a3f..37fef333 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt @@ -413,14 +413,17 @@ object NsiteManager { // region install / update /** Installs an nsite discovered via [discover]. Returns the stored [NsiteInfo] or a failure. */ - suspend fun install(discovered: DiscoveredNsite): Result = withContext(Dispatchers.IO) { + suspend fun install( + discovered: DiscoveredNsite, + onProgress: ((downloaded: Int, total: Int) -> Unit)? = null, + ): Result = withContext(Dispatchers.IO) { if (Citrine.isImportingEvents) return@withContext Result.failure(IllegalStateException("Busy importing events")) if (!installing.compareAndSet(false, true)) return@withContext Result.failure(IllegalStateException("Another nsite operation is running")) try { val relays = discoveryRelays() val manifest = fetchManifest(discovered.pubkey, discovered.kind, discovered.dTag, relays) ?: return@withContext Result.failure(IllegalStateException("Could not fetch manifest")) - downloadAndStore(discovered.address, discovered.pubkey, discovered.kind, discovered.dTag, discovered.displayName, manifest, relays) + downloadAndStore(discovered.address, discovered.pubkey, discovered.kind, discovered.dTag, discovered.displayName, manifest, relays, onProgress = onProgress) } finally { installing.set(false) } @@ -457,6 +460,7 @@ object NsiteManager { manifest: Event, relays: List, autoUpdate: Boolean = false, + onProgress: ((downloaded: Int, total: Int) -> Unit)? = null, ): Result { val paths = pathTagsOf(manifest) if (paths.isEmpty()) return Result.failure(IllegalStateException("Manifest has no files")) @@ -472,7 +476,8 @@ object NsiteManager { try { val tmpCanonical = tmpDir.canonicalFile - for ((urlPath, sha256) in paths) { + onProgress?.invoke(0, paths.size) + paths.forEachIndexed { index, (urlPath, sha256) -> val outFile = File(tmpCanonical, urlPath.trimStart('/')).canonicalFile if (outFile.path != tmpCanonical.path && !outFile.path.startsWith(tmpCanonical.path + File.separator)) { return Result.failure(IllegalStateException("Illegal path in manifest: $urlPath")) @@ -481,6 +486,7 @@ object NsiteManager { ?: return Result.failure(IllegalStateException("Failed to download $urlPath")) outFile.parentFile?.mkdirs() outFile.writeBytes(bytes) + onProgress?.invoke(index + 1, paths.size) } } catch (e: Exception) { tmpDir.deleteRecursively() diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt index 22926398..2aede981 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/BrowseNsitesScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -41,6 +42,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private data class InstallUiState( + val address: String, + val downloaded: Int, + val total: Int, +) + @Composable fun BrowseNsitesScreen( modifier: Modifier = Modifier, @@ -49,6 +56,7 @@ fun BrowseNsitesScreen( val context = LocalContext.current val state by NsiteManager.discoveryState.collectAsState() var searchQuery by remember { mutableStateOf(TextFieldValue("")) } + var installing by remember { mutableStateOf(null) } LaunchedEffect(Unit) { withContext(Dispatchers.IO) { @@ -108,14 +116,18 @@ fun BrowseNsitesScreen( } else { LazyColumn(modifier = Modifier.fillMaxSize()) { items(filtered) { nsite -> + val rowInstalling = installing?.takeIf { it.address == nsite.address } Row( modifier = Modifier .fillMaxWidth() - .clickable(enabled = !nsite.alreadyInstalled) { - Toast.makeText(context, context.getString(R.string.installing_nsite, nsite.displayName), Toast.LENGTH_SHORT).show() + .clickable(enabled = !nsite.alreadyInstalled && installing == null) { + installing = InstallUiState(nsite.address, 0, 0) scope.launch(Dispatchers.IO) { - val result = NsiteManager.install(nsite) + val result = NsiteManager.install(nsite) { downloaded, total -> + installing = InstallUiState(nsite.address, downloaded, total) + } withContext(Dispatchers.Main) { + installing = null val message = if (result.isSuccess) { context.getString(R.string.nsite_installed, nsite.displayName) } else { @@ -130,7 +142,20 @@ fun BrowseNsitesScreen( .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - NsiteIcon(model = nsite.iconUrl) + if (rowInstalling != null) { + Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) { + if (rowInstalling.total > 0) { + CircularProgressIndicator( + progress = { rowInstalling.downloaded.toFloat() / rowInstalling.total }, + modifier = Modifier.size(28.dp), + ) + } else { + CircularProgressIndicator(modifier = Modifier.size(28.dp)) + } + } + } else { + NsiteIcon(model = nsite.iconUrl) + } Column(modifier = Modifier.padding(start = 12.dp)) { Text( nsite.displayName, @@ -140,6 +165,9 @@ fun BrowseNsitesScreen( ) Text( when { + rowInstalling != null && rowInstalling.total > 0 -> + stringResource(R.string.nsite_installing_progress, rowInstalling.downloaded, rowInstalling.total) + rowInstalling != null -> stringResource(R.string.installing_nsite, nsite.displayName) nsite.alreadyInstalled -> stringResource(R.string.nsite_already_installed) nsite.authorName.isNotBlank() -> stringResource(R.string.nsite_by_author, nsite.authorName) else -> nsite.address diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89bbbb97..89ce9731 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -179,6 +179,7 @@ No nsites found Search nsites Installing %1$s… + Installing… %1$d/%2$d files Installed %1$s Failed to install %1$s Already installed From f1118bc7f10eeefa3a1181fcd9488a16ec10a8a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 20:32:56 +0000 Subject: [PATCH 6/8] Add a setting to choose relays for fetching nsites Add a configurable nsite relay list (Settings.nsiteRelays, persisted) that NsiteManager.discoveryRelays() uses when non-empty, falling back to the aggregator relays otherwise. New NsiteRelaysSettingsScreen (reusing the relay-list editor components) is reachable from the Web Clients nsites section, with add/delete and a reset-to-default action. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgPUkQoCoYH2Fz9bPxK1j7 --- .../greenart7c3/citrine/server/Settings.kt | 12 ++ .../citrine/service/LocalPreferences.kt | 3 + .../citrine/service/NsiteManager.kt | 15 ++- .../greenart7c3/citrine/ui/CitrineScaffold.kt | 5 + .../citrine/ui/navigation/Route.kt | 7 ++ .../ui/settings/NsiteRelaysSettingsScreen.kt | 110 ++++++++++++++++++ .../ui/settings/WebClientsSettingsScreen.kt | 13 ++- app/src/main/res/values/strings.xml | 4 + 8 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/citrine/ui/settings/NsiteRelaysSettingsScreen.kt diff --git a/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt b/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt index 8e8d7c20..7b1b0bd1 100644 --- a/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt +++ b/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt @@ -16,6 +16,14 @@ object Settings { "wss://directory.yabu.me/", ) + // Suggested relays for discovering nsites (NIP-5A). Used by the "reset to default" action + // in the nsite relay editor; an empty user list falls back to the aggregator relays. + val DEFAULT_NSITE_RELAYS = setOf( + "wss://relay.damus.io/", + "wss://nos.lol/", + "wss://relay.primal.net/", + ) + var allowedKinds: Set = emptySet() var allowedPubKeys: Set = emptySet() var allowedTaggedPubKeys: Set = emptySet() @@ -55,6 +63,9 @@ object Settings { // 100s service timer only runs the check roughly once per day. var lastNsiteCheck: Long = 0L + // Relays queried to discover nsites. Empty = fall back to the aggregator relays. + var nsiteRelays: Set = emptySet() + var relayAggregatorEnabled = false var aggregatorPubkey = "" var relayAggregatorKinds: Set = setOf(0, 1, 3, 5, 6, 7, 1111, 10000, 10002, 30023) @@ -125,6 +136,7 @@ object Settings { webClients = mutableMapOf() nsites = mutableListOf() lastNsiteCheck = 0L + nsiteRelays = emptySet() relayAggregatorEnabled = false aggregatorPubkey = "" relayAggregatorKinds = setOf(0, 1, 3, 5, 6, 7, 1111, 10000, 10002, 30023) diff --git a/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt index 2ff91441..e76de9da 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt @@ -36,6 +36,7 @@ object PrefKeys { const val WEB_CLIENTS = "web_clients" const val NSITES = "nsites" const val LAST_NSITE_CHECK = "last_nsite_check" + const val NSITE_RELAYS = "nsite_relays" const val RELAY_AGGREGATOR_ENABLED = "relay_aggregator_enabled" const val AGGREGATOR_PUBKEY = "aggregator_pubkey" @@ -105,6 +106,7 @@ object LocalPreferences { remove(PrefKeys.NSITES) } putLong(PrefKeys.LAST_NSITE_CHECK, settings.lastNsiteCheck) + putStringSet(PrefKeys.NSITE_RELAYS, settings.nsiteRelays) putBoolean(PrefKeys.RELAY_AGGREGATOR_ENABLED, settings.relayAggregatorEnabled) putString(PrefKeys.AGGREGATOR_PUBKEY, settings.aggregatorPubkey) @@ -167,6 +169,7 @@ object LocalPreferences { Settings.nsites = Settings.nsitesFromJson(it) } Settings.lastNsiteCheck = prefs.getLong(PrefKeys.LAST_NSITE_CHECK, 0L) + Settings.nsiteRelays = prefs.getStringSet(PrefKeys.NSITE_RELAYS, emptySet()) ?: emptySet() Settings.relayAggregatorEnabled = prefs.getBoolean(PrefKeys.RELAY_AGGREGATOR_ENABLED, false) Settings.aggregatorPubkey = prefs.getString(PrefKeys.AGGREGATOR_PUBKEY, "") ?: "" diff --git a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt index 37fef333..1e636af5 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt @@ -115,12 +115,15 @@ object NsiteManager { } private fun discoveryRelays(): List { - val configured = ( - Settings.relayAggregatorSourceRelays + - Settings.relayAggregatorIndexerRelays + - Settings.relayAggregatorExtraRelays - ) - val raw = configured.takeIf { it.isNotEmpty() } ?: FALLBACK_RELAYS + val raw = when { + // User-chosen nsite relays take precedence. + Settings.nsiteRelays.isNotEmpty() -> Settings.nsiteRelays + else -> ( + Settings.relayAggregatorSourceRelays + + Settings.relayAggregatorIndexerRelays + + Settings.relayAggregatorExtraRelays + ).takeIf { it.isNotEmpty() } ?: FALLBACK_RELAYS + } return raw.mapNotNull { normalizeRemote(it) }.ifEmpty { FALLBACK_RELAYS.mapNotNull { normalizeRemote(it) } } } diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt b/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt index 012841cf..58f8df64 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/CitrineScaffold.kt @@ -57,6 +57,7 @@ import com.greenart7c3.citrine.ui.settings.AggregatorSettingsScreen import com.greenart7c3.citrine.ui.settings.BackupSettingsScreen import com.greenart7c3.citrine.ui.settings.BrowseNsitesScreen import com.greenart7c3.citrine.ui.settings.NetworkSettingsScreen +import com.greenart7c3.citrine.ui.settings.NsiteRelaysSettingsScreen import com.greenart7c3.citrine.ui.settings.RelayInfoSettingsScreen import com.greenart7c3.citrine.ui.settings.RetentionSettingsScreen import com.greenart7c3.citrine.ui.settings.WebClientsSettingsScreen @@ -336,11 +337,15 @@ fun CitrineScaffold( storageHelper = storageHelper, onApplyChanges = restartRelay, onBrowseNsites = { navController.navigate(Route.BrowseNsites.route) }, + onNsiteRelays = { navController.navigate(Route.NsiteRelaysSettings.route) }, ) } composable(Route.BrowseNsites.route) { BrowseNsitesScreen(modifier = subScreenModifier) } + composable(Route.NsiteRelaysSettings.route) { + NsiteRelaysSettingsScreen(modifier = subScreenModifier) + } composable(Route.DatabaseInfo.route) { val context = LocalContext.current diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt b/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt index df8c0cce..dc5e2965 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/navigation/Route.kt @@ -103,6 +103,12 @@ sealed class Route( icon = Icons.Outlined.Settings, selectedIcon = Icons.Default.Settings, ) + + data object NsiteRelaysSettings : Route( + route = "Settings/NsiteRelays", + icon = Icons.Outlined.Settings, + selectedIcon = Icons.Default.Settings, + ) } val navigationItems = listOf(Route.Home, Route.Settings) @@ -116,4 +122,5 @@ val settingsSubRoutes = setOf( Route.BackupSettings.route, Route.WebClientsSettings.route, Route.BrowseNsites.route, + Route.NsiteRelaysSettings.route, ) diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/NsiteRelaysSettingsScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/NsiteRelaysSettingsScreen.kt new file mode 100644 index 00000000..f17c9452 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/NsiteRelaysSettingsScreen.kt @@ -0,0 +1,110 @@ +package com.greenart7c3.citrine.ui.settings + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.greenart7c3.citrine.R +import com.greenart7c3.citrine.server.Settings +import com.greenart7c3.citrine.service.LocalPreferences +import com.greenart7c3.citrine.ui.components.PubkeyInputRow +import com.greenart7c3.citrine.ui.components.PubkeyListItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun NsiteRelaysSettingsScreen( + modifier: Modifier = Modifier, +) { + var isLoading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + val clipboardManager = LocalClipboard.current + + Surface(modifier) { + var nsiteRelays by remember { mutableStateOf(Settings.nsiteRelays) } + var nsiteRelayInput by remember { mutableStateOf(TextFieldValue("")) } + + val applyChanges = { + scope.launch(Dispatchers.IO) { + isLoading = true + Settings.nsiteRelays = nsiteRelays + LocalPreferences.saveSettingsToEncryptedStorage(Settings, context) + delay(800) + isLoading = false + } + Unit + } + + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.weight(1f)) { + item { + Text( + text = stringResource(R.string.nsite_relays_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + item { + PubkeyInputRow( + value = nsiteRelayInput, + onValueChange = { nsiteRelayInput = it }, + onPaste = { + scope.launch { + val text = clipboardManager.getClipEntry()?.clipData?.getItemAt(0)?.text?.toString() ?: return@launch + nsiteRelayInput = TextFieldValue(text) + } + }, + onAdd = { + val normalized = normalizeRelayInput(nsiteRelayInput.text) + if (normalized == null) { + Toast.makeText(context, context.getString(R.string.relay_aggregator_invalid_relay), Toast.LENGTH_SHORT).show() + return@PubkeyInputRow + } + nsiteRelays = nsiteRelays + normalized + nsiteRelayInput = TextFieldValue("") + }, + ) + } + item { + TextButton(onClick = { nsiteRelays = Settings.DEFAULT_NSITE_RELAYS }) { + Text(stringResource(R.string.reset_to_default)) + } + } + if (nsiteRelays.isEmpty()) { + item { + EmptyListHint(stringResource(R.string.nsite_relays_hint)) + } + } + items(nsiteRelays.toList()) { relay -> + PubkeyListItem( + text = relay, + onDelete = { nsiteRelays = nsiteRelays - relay }, + ) + } + } + + SettingsApplyBar(enabled = !isLoading, onApply = applyChanges) + } + } +} diff --git a/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt b/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt index bfec0a61..faf9ee08 100644 --- a/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt +++ b/app/src/main/java/com/greenart7c3/citrine/ui/settings/WebClientsSettingsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -46,6 +47,7 @@ fun WebClientsSettingsScreen( storageHelper: SimpleStorageHelper, onApplyChanges: () -> Unit, onBrowseNsites: () -> Unit, + onNsiteRelays: () -> Unit, ) { var isLoading by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -113,14 +115,14 @@ fun WebClientsSettingsScreen( style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 4.dp), ) + Button(onClick = onBrowseNsites) { + Text(stringResource(R.string.browse_nsites)) + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Button(onClick = onBrowseNsites) { - Text(stringResource(R.string.browse_nsites)) - } - Button( + TextButton( onClick = { scope.launch(Dispatchers.IO) { val updates = NsiteManager.checkForUpdates() @@ -131,6 +133,9 @@ fun WebClientsSettingsScreen( ) { Text(stringResource(R.string.check_for_updates)) } + TextButton(onClick = onNsiteRelays) { + Text(stringResource(R.string.nsite_relays)) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89ce9731..0c21ee14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -185,6 +185,10 @@ Already installed by %1$s Update available for %1$s + Nsite relays + Relays queried to discover nsites. Leave empty to use the aggregator relays. + No relays added — using the aggregator relays. + Reset to default Reset all settings All settings will be restored to their default values. This action cannot be undone. Settings reset to defaults From 2f17dacb078b38498a5fda9519bce3ae693f14f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 16:28:14 +0000 Subject: [PATCH 7/8] Use dedicated default relays for nsites, never the aggregator relays nsite discovery now uses only the nsite relay set (defaulting to DEFAULT_NSITE_RELAYS) and no longer falls back to the relay aggregator relays. nsiteRelays defaults to the built-in set on fresh installs and on reset. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgPUkQoCoYH2Fz9bPxK1j7 --- .../greenart7c3/citrine/server/Settings.kt | 7 ++++--- .../citrine/service/LocalPreferences.kt | 2 +- .../citrine/service/NsiteManager.kt | 20 ++++--------------- app/src/main/res/values/strings.xml | 4 ++-- 4 files changed, 11 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt b/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt index 7b1b0bd1..04830e29 100644 --- a/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt +++ b/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt @@ -63,8 +63,9 @@ object Settings { // 100s service timer only runs the check roughly once per day. var lastNsiteCheck: Long = 0L - // Relays queried to discover nsites. Empty = fall back to the aggregator relays. - var nsiteRelays: Set = emptySet() + // Relays queried to discover nsites. nsites use only this set (never the aggregator + // relays); an empty list falls back to [DEFAULT_NSITE_RELAYS]. + var nsiteRelays: Set = DEFAULT_NSITE_RELAYS var relayAggregatorEnabled = false var aggregatorPubkey = "" @@ -136,7 +137,7 @@ object Settings { webClients = mutableMapOf() nsites = mutableListOf() lastNsiteCheck = 0L - nsiteRelays = emptySet() + nsiteRelays = DEFAULT_NSITE_RELAYS relayAggregatorEnabled = false aggregatorPubkey = "" relayAggregatorKinds = setOf(0, 1, 3, 5, 6, 7, 1111, 10000, 10002, 30023) diff --git a/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt index e76de9da..2df1304f 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/LocalPreferences.kt @@ -169,7 +169,7 @@ object LocalPreferences { Settings.nsites = Settings.nsitesFromJson(it) } Settings.lastNsiteCheck = prefs.getLong(PrefKeys.LAST_NSITE_CHECK, 0L) - Settings.nsiteRelays = prefs.getStringSet(PrefKeys.NSITE_RELAYS, emptySet()) ?: emptySet() + Settings.nsiteRelays = prefs.getStringSet(PrefKeys.NSITE_RELAYS, null) ?: Settings.DEFAULT_NSITE_RELAYS Settings.relayAggregatorEnabled = prefs.getBoolean(PrefKeys.RELAY_AGGREGATOR_ENABLED, false) Settings.aggregatorPubkey = prefs.getString(PrefKeys.AGGREGATOR_PUBKEY, "") ?: "" diff --git a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt index 1e636af5..1546d72b 100644 --- a/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt +++ b/app/src/main/java/com/greenart7c3/citrine/service/NsiteManager.kt @@ -53,12 +53,6 @@ object NsiteManager { private const val DISCOVERY_TIMEOUT_MS = 20_000L private const val DISCOVERY_LIMIT = 500 - private val FALLBACK_RELAYS = setOf( - "wss://relay.damus.io/", - "wss://nos.lol/", - "wss://relay.primal.net/", - ) - private val DEFAULT_BLOSSOM_SERVERS = listOf("https://blossom.primal.net") // Common raster icon filenames, in priority order. Coil can't decode .ico/.svg, so those @@ -115,16 +109,10 @@ object NsiteManager { } private fun discoveryRelays(): List { - val raw = when { - // User-chosen nsite relays take precedence. - Settings.nsiteRelays.isNotEmpty() -> Settings.nsiteRelays - else -> ( - Settings.relayAggregatorSourceRelays + - Settings.relayAggregatorIndexerRelays + - Settings.relayAggregatorExtraRelays - ).takeIf { it.isNotEmpty() } ?: FALLBACK_RELAYS - } - return raw.mapNotNull { normalizeRemote(it) }.ifEmpty { FALLBACK_RELAYS.mapNotNull { normalizeRemote(it) } } + // nsites use their own relay set only — never the aggregator relays. An empty user + // list falls back to the built-in defaults. + val raw = Settings.nsiteRelays.ifEmpty { Settings.DEFAULT_NSITE_RELAYS } + return raw.mapNotNull { normalizeRemote(it) }.ifEmpty { Settings.DEFAULT_NSITE_RELAYS.mapNotNull { normalizeRemote(it) } } } // endregion diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c21ee14..171d4302 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -186,8 +186,8 @@ by %1$s Update available for %1$s Nsite relays - Relays queried to discover nsites. Leave empty to use the aggregator relays. - No relays added — using the aggregator relays. + Relays queried to discover nsites. Leave empty to use the default relays. + No relays added — using the default relays. Reset to default Reset all settings All settings will be restored to their default values. This action cannot be undone. From cae14ac470be1a3edcc163618a118a34f901856d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 16:37:25 +0000 Subject: [PATCH 8/8] Set nsite default relays to nsite.run, nos.lol, nostr.land Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgPUkQoCoYH2Fz9bPxK1j7 --- app/src/main/java/com/greenart7c3/citrine/server/Settings.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt b/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt index 04830e29..17706703 100644 --- a/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt +++ b/app/src/main/java/com/greenart7c3/citrine/server/Settings.kt @@ -19,9 +19,9 @@ object Settings { // Suggested relays for discovering nsites (NIP-5A). Used by the "reset to default" action // in the nsite relay editor; an empty user list falls back to the aggregator relays. val DEFAULT_NSITE_RELAYS = setOf( - "wss://relay.damus.io/", + "wss://nsite.run/", "wss://nos.lol/", - "wss://relay.primal.net/", + "wss://nostr.land/", ) var allowedKinds: Set = emptySet()