feat: show website favicon and domain in all browser-connected app views

Web app entries (browser:https://...) now display:
- The captured favicon from BrowserIconRegistry (same source as the
  bottom-nav favourite website icon) in ConnectedAppsScreen,
  ConnectedAppDetailScreen, and all three permission/consent dialogs
- The domain name (host) as the title instead of the full URL, via
  OmniboxInput.hostOf() in loadDetailState, NappletConsentSummary,
  buildSignerConsentInfo, and buildConnectInfo
- A globe icon (FavoriteApp.WebApp) instead of the grid icon
  (FavoriteApp.NostrApp) in all consent dialog headers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
This commit is contained in:
Claude
2026-06-28 15:41:27 +00:00
parent 6f9bf33595
commit 3cc0ce09d4
8 changed files with 87 additions and 10 deletions
@@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplethost.NappletBlobCache
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
@@ -107,3 +108,19 @@ private fun resolveIconBlob(event: Event?): IconBlob? =
is NamedSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
else -> null
}
/**
* A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon
* has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the
* WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache.
*
* Early-returns null when [url] is blank or has no parseable host — this early return is stable
* for a given [url] (the host either always parses or never does), so composition structure is
* preserved across recompositions.
*/
@Composable
fun rememberWebAppIconModel(url: String): String? {
val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
}
@@ -146,8 +146,14 @@ private fun NappletConnectScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val isBrowser = info.coordinate.startsWith("browser:")
FavoriteAppIcon(
app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl),
app =
if (isBrowser) {
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
} else {
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
},
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
@@ -138,8 +138,14 @@ private fun NappletConsentDialog(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val isBrowser = info.coordinate.startsWith("browser:")
FavoriteAppIcon(
app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl),
app =
if (isBrowser) {
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
} else {
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
},
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
@@ -42,7 +44,13 @@ class NappletConsentSummary(
request: NappletRequest,
): NappletConsentInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) = resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
host to BrowserIconRegistry.iconModelFor(host)
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
return NappletConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
@@ -143,8 +143,14 @@ private fun NappletSignerConsentDialog(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val isBrowser = info.coordinate.startsWith("browser:")
FavoriteAppIcon(
app = FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl),
app =
if (isBrowser) {
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
} else {
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
},
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
@@ -46,7 +48,13 @@ fun buildSignerConsentInfo(
request: NappletRequest,
): NappletSignerConsentInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) = resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
host to BrowserIconRegistry.iconModelFor(host)
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
val summary = op.label(context)
val preview =
when (request) {
@@ -93,7 +101,18 @@ fun buildConnectInfo(
identity: NappletIdentity,
): NappletConnectInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) = resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
val domain = identity.identifier.ifBlank { identity.authorPubKey.take(12) + "" }
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
host to BrowserIconRegistry.iconModelFor(host)
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
val domain =
if (identity.authorPubKey == "browser") {
OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
} else {
identity.identifier.ifBlank { identity.authorPubKey.take(12) + "" }
}
return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl)
}
@@ -57,6 +57,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -69,6 +70,8 @@ import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel
import com.vitorpamplona.amethyst.napplet.descriptionRes
import com.vitorpamplona.amethyst.napplet.labelRes
import com.vitorpamplona.amethyst.napplet.resolveNappletMeta
@@ -238,14 +241,17 @@ private fun AppIdentityHeader(state: ConnectedAppDetailState) {
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val isBrowserEntry = state.coordinate.startsWith("browser:")
val browserUrl = if (isBrowserEntry) state.coordinate.substringAfter(':') else null
val iconModel = if (browserUrl != null) rememberWebAppIconModel(browserUrl) else null
val appForIcon =
if (isBrowserEntry) {
FavoriteApp.WebApp(state.coordinate.substringAfter(':'), state.title, 0L)
FavoriteApp.WebApp(browserUrl ?: "", state.title, 0L)
} else {
FavoriteApp.NostrApp(state.coordinate, state.title, 0L, state.iconUrl)
}
FavoriteAppIcon(
app = appForIcon,
iconModel = iconModel,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(48.dp),
)
@@ -449,7 +455,13 @@ private suspend fun loadDetailState(
val signerPolicy = signerLedger.store.loadPolicy(coordinate)
val opOverrides = signerLedger.store.allOpDecisions(coordinate)
val (title, iconUrl) = resolveNappletMeta(author, identifier, untitled)
val (title, iconUrl) =
if (author == "browser") {
val host = OmniboxInput.hostOf(identifier) ?: identifier
host to BrowserIconRegistry.iconModelFor(host)
} else {
resolveNappletMeta(author, identifier, untitled)
}
return ConnectedAppDetailState(
title = title,
coordinate = coordinate,
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -207,9 +208,11 @@ private fun BrowserAppCard(
.ifBlank { url }
}
val iconModel = rememberWebAppIconModel(url)
ConnectedAppCardLayout(
app = FavoriteApp.WebApp(url, domain, 0L),
iconModel = null,
iconModel = iconModel,
title = domain,
subtitle = url,
npub = null,