From d33d1e36fda06ed1eedd806de36dfb24c73b503e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 01:16:06 +0000 Subject: [PATCH] feat: move favorite to pull-down sheet, remove from OmniBar Remove the star icon from the browser URL bar (OmniBar) and add a favorite toggle row to both pull-down sheet surfaces instead: - Compose TopControlSheet (embedded web tabs and napplets): shows filled/outline star with "Add to favorites" / "Remove from favorites" sourced from FavoriteAppsRegistry; isFavorite state flows reactively through EmbeddedTabChrome so the label updates without reopening. - Native NappletControlSheet (full-screen NappletBrowserActivity): same toggle backed by a new MSG_TOGGLE_WEB_FAVORITE IPC message handled in NappletBrokerService; initial state is passed via intent so the star opens in the correct filled/outline state for the launch URL. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SBpBE7bQJ2JRni6sYG8UDo --- .../amethyst/favorites/FavoriteAppLauncher.kt | 3 +- .../amethyst/napplet/NappletBrokerService.kt | 17 +++++++ .../screen/loggedIn/browser/BrowserScreen.kt | 10 ---- .../loggedIn/browser/FavoriteWebAppScreen.kt | 17 ++++++- .../loggedIn/embed/EmbeddedTabChrome.kt | 4 ++ .../screen/loggedIn/embed/TopControlSheet.kt | 9 ++++ .../favorites/FavoriteNappletScreen.kt | 18 ++++++- .../napplethost/NappletBrowserActivity.kt | 23 +++++++++ .../napplethost/NappletControlSheet.kt | 47 +++++++++++++++++++ .../amethyst/napplethost/NappletIpc.kt | 14 ++++++ nappletHost/src/main/res/values/strings.xml | 4 ++ 11 files changed, 152 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index 049759b440..703ab00da4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -77,8 +77,9 @@ object FavoriteAppLauncher { ) { val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 val useTor = proxyPort > 0 && (preferTor || WebUrlNetworkRegistry.useTor(url)) + val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url") val intent = - NappletBrowserActivity.intent(context, url, proxyPort, useTor).apply { + NappletBrowserActivity.intent(context, url, proxyPort, useTor, isFavorite = isFavorite).apply { if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index fd2b6a68f3..8f2dba934d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -32,6 +32,7 @@ import android.os.RemoteException import android.os.SystemClock import android.util.Log import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.napplet.NappletBroker import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity @@ -41,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways import com.vitorpamplona.amethyst.napplethost.NappletIpc @@ -175,6 +177,21 @@ class NappletBrokerService : Service() { return true } + // The direct-WebView browser requests a favorite toggle for the current URL (main process only). + if (msg.what == NappletIpc.MSG_TOGGLE_WEB_FAVORITE) { + val data = msg.data ?: return true + val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true + val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url } + FavoriteAppsRegistry.init(applicationContext) + val id = "url:$url" + if (FavoriteAppsRegistry.isFavorite(id)) { + FavoriteAppsRegistry.remove(id) + } else { + FavoriteAppsRegistry.add(FavoriteApp.WebUrl(url, label, System.currentTimeMillis())) + } + return true + } + // The direct-WebView browser relays its per-host Tor choice; persist it (main process only). if (msg.what == NappletIpc.MSG_SET_WEB_TOR) { val data = msg.data ?: return true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index ba72b350ee..e4e9d24fa4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -195,12 +195,6 @@ private fun BrowserLauncher( onValueChange = ::onValueChange, onClear = { field = TextFieldValue("") }, onOpen = { open(field.text) }, - onFavorite = { - val url = OmniboxInput.resolve(field.text)?.url ?: return@OmniBar - FavoriteAppsRegistry.add( - FavoriteApp.WebUrl(url = url, label = hostOf(url), addedAt = System.currentTimeMillis()), - ) - }, ) }, bottomBar = { @@ -264,7 +258,6 @@ private fun OmniBar( onValueChange: (TextFieldValue) -> Unit, onClear: () -> Unit, onOpen: () -> Unit, - onFavorite: () -> Unit, ) { Row( modifier = @@ -309,9 +302,6 @@ private fun OmniBar( ), ) if (field.text.isNotBlank()) { - IconButton(onClick = onFavorite) { - Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add)) - } IconButton(onClick = onOpen) { Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringResource(R.string.browser_go)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt index 52256d3870..4020781557 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt @@ -44,9 +44,12 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds @@ -104,6 +107,9 @@ private fun EmbeddedFavoriteTab( // can opt one out and it must stick). Only meaningful when Tor is actually available. var torOn by remember { mutableStateOf(proxyAvailable && WebUrlNetworkRegistry.useTor(url)) } + val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebUrl && it.url == currentUrl } } + val backgroundColor = MaterialTheme.colorScheme.background.toArgb() val controller = @@ -121,7 +127,7 @@ private fun EmbeddedFavoriteTab( // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. val chrome = - remember(currentUrl, torOn, proxyAvailable) { + remember(currentUrl, torOn, proxyAvailable, isFavorite) { EmbeddedTabChrome( title = hostLabel(currentUrl), isSandbox = false, @@ -133,6 +139,15 @@ private fun EmbeddedFavoriteTab( controller.setTor(torOn) WebUrlNetworkRegistry.set(url, torOn) }, + isFavorite = isFavorite, + onFavorite = { + val favId = "url:$currentUrl" + if (FavoriteAppsRegistry.isFavorite(favId)) { + FavoriteAppsRegistry.remove(favId) + } else { + FavoriteAppsRegistry.add(FavoriteApp.WebUrl(currentUrl, hostLabel(currentUrl), System.currentTimeMillis())) + } + }, ) } // Publish the top-sheet controls to the tab layer (which draws them over the z-below surface). In a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt index 753dffb034..42ef553923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt @@ -37,4 +37,8 @@ data class EmbeddedTabChrome( val onToggleTor: () -> Unit = {}, /** The "what it can access" sheet, for sandboxed napplets/nsites; null for a plain web client. */ val onInfo: (() -> Unit)? = null, + /** Whether the current URL/app is already saved as a favorite. */ + val isFavorite: Boolean = false, + /** Toggles the current site/app in the favorites registry; null when not applicable. */ + val onFavorite: (() -> Unit)? = null, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt index 48c48049d3..0ebe85233d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt @@ -131,6 +131,15 @@ fun TopControlSheet( onExpandedChange(false) chrome.onOpenFull() } + chrome.onFavorite?.let { toggleFavorite -> + SheetItem( + if (chrome.isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder, + stringResource(if (chrome.isFavorite) R.string.favorite_app_remove else R.string.favorite_app_add), + ) { + onExpandedChange(false) + toggleFavorite() + } + } onConsole?.let { showConsole -> SheetItem( MaterialSymbols.Code, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt index 0cf2d6156c..e93588b49e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt @@ -53,9 +53,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -125,6 +127,9 @@ private fun EmbeddedNappletTab( var canGoBack by remember { mutableStateOf(false) } var showAccess by remember { mutableStateOf(false) } + val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } } + val controller = remember(id) { EmbeddedTabFactory.acquireNapplet(context, coordinate, params, backgroundColor) @@ -138,15 +143,24 @@ private fun EmbeddedNappletTab( } } - // Stable per app (title/coordinate don't change), so the tab layer isn't recomposed every frame. + // Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame. val chrome = - remember(title, coordinate) { + remember(title, coordinate, isFavorite) { EmbeddedTabChrome( title = title.ifBlank { coordinate }, isSandbox = true, onReload = { controller.reload() }, onOpenFull = { FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) }, onInfo = { showAccess = true }, + isFavorite = isFavorite, + onFavorite = { + val favId = "nostr:$coordinate" + if (FavoriteAppsRegistry.isFavorite(favId)) { + FavoriteAppsRegistry.remove(favId) + } else { + FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) + } + }, ) } // Publish the top-sheet controls to the tab layer (drawn over the z-below surface). In a SideEffect diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index dc3b8c9e6e..d45442ace0 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -590,8 +590,28 @@ class NappletBrowserActivity : ComponentActivity() { liveUrl = startUrl, onNavigate = { loadAddress(it) }, onConsole = { consolePanel?.toggle() }, + isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false), + onFavoriteToggle = { url, _ -> sendFavoriteToggle(url) }, ).also { controlSheet = it } + private fun sendFavoriteToggle(url: String) { + val host = + runCatching { + android.net.Uri + .parse(url) + .host + }.getOrNull()?.takeIf { it.isNotBlank() } ?: url + val msg = + Message.obtain(null, NappletIpc.MSG_TOGGLE_WEB_FAVORITE).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_FAVORITE_URL, url) + putString(NappletIpc.KEY_FAVORITE_LABEL, host) + } + } + if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + } + private fun buildConsolePanel(): View = NappletConsolePanel(this).also { it.onClearCallback = { controlSheet?.updateConsoleCount(0) } @@ -639,6 +659,7 @@ class NappletBrowserActivity : ComponentActivity() { private const val EXTRA_PROXY_PORT = "proxyPort" private const val EXTRA_USE_TOR = "useTor" private const val EXTRA_TITLE = "title" + private const val EXTRA_IS_FAVORITE = "isFavorite" fun intent( context: Context, @@ -646,6 +667,7 @@ class NappletBrowserActivity : ComponentActivity() { proxyPort: Int, useTor: Boolean, title: String = "", + isFavorite: Boolean = false, ): Intent = Intent() .setClassName(context, "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity") @@ -653,6 +675,7 @@ class NappletBrowserActivity : ComponentActivity() { .putExtra(EXTRA_PROXY_PORT, proxyPort) .putExtra(EXTRA_USE_TOR, useTor) .putExtra(EXTRA_TITLE, title) + .putExtra(EXTRA_IS_FAVORITE, isFavorite) // Distinct task identity per URL for documentLaunchMode=intoExisting. .setData(Uri.parse(url)) } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt index d474152177..78d3d5bec2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt @@ -67,6 +67,9 @@ class NappletControlSheet( // When non-null, a "Console" row is added to the pull-down sheet. The callback toggles the // browser's console log panel; the count label is updated via [updateConsoleCount]. private val onConsole: (() -> Unit)? = null, + // When non-null, a favorite toggle row is shown; called with the current URL and new isFavorite state. + isFavoriteInitially: Boolean = false, + private val onFavoriteToggle: ((url: String, isFavorite: Boolean) -> Unit)? = null, ) : LinearLayout(context) { private val onSurface = resolveThemeColor(android.R.attr.textColorPrimary) private val dimmed = resolveThemeColor(android.R.attr.textColorSecondary) @@ -75,6 +78,7 @@ class NappletControlSheet( private var expanded = false private var torOn = torInitiallyOn private var currentUrl = liveUrl + private var isFavorite = isFavoriteInitially private val panel: LinearLayout private var torLabel: TextView? = null @@ -82,6 +86,7 @@ class NappletControlSheet( private var addressField: EditText? = null private var securityGlyph: TextView? = null private var consoleLabel: TextView? = null + private var favoriteLabel: TextView? = null init { orientation = VERTICAL @@ -156,6 +161,35 @@ class NappletControlSheet( }, ) } + onFavoriteToggle?.let { + val label = + TextView(context).apply { + text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add) + setTextColor(onSurface) + textSize = 15f + setPadding(dp(8), 0, 0, 0) + } + favoriteLabel = label + addView( + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(8), dp(10), dp(8), dp(10)) + isClickable = true + setOnClickListener { toggleFavorite() } + addView( + TextView(context).apply { + text = "★" + setTextColor(dimmed) + textSize = 18f + width = dp(28) + gravity = Gravity.CENTER + }, + ) + addView(label) + }, + ) + } } private fun titleRow(): View = @@ -254,6 +288,19 @@ class NappletControlSheet( // Don't fight the user while they're editing the field. addressField?.takeIf { !it.hasFocus() }?.setText(url) securityGlyph?.text = securityGlyphFor(url) + // Reset favorite state for the new URL (we don't know if it's a favorite without a round-trip). + if (onFavoriteToggle != null) { + isFavorite = false + favoriteLabel?.text = context.getString(R.string.browser_favorite_add) + } + } + + private fun toggleFavorite() { + val url = currentUrl?.takeIf { it.isNotBlank() } ?: return + isFavorite = !isFavorite + favoriteLabel?.text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add) + collapse() + onFavoriteToggle?.invoke(url, isFavorite) } private fun securityGlyphFor(url: String): String = diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt index fd2913e607..d740e5a03a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -89,6 +89,14 @@ object NappletIpc { */ const val MSG_RECORD_ICON = 10 + /** + * Host → broker (browser mode): toggle a URL in the main-process favorites registry. Carries + * [KEY_FAVORITE_URL] and [KEY_FAVORITE_LABEL]. The broker adds the URL if it isn't already + * a favorite, or removes it if it is — identical to the in-app star toggle on the home screen. + * Fire-and-forget; no reply needed. + */ + const val MSG_TOGGLE_WEB_FAVORITE = 11 + const val KEY_REQUEST_ID = "requestId" const val KEY_PAYLOAD = "payload" @@ -107,6 +115,12 @@ object NappletIpc { /** The bare host (e.g. `example.com`) a browser Tor choice belongs to. */ const val KEY_WEB_HOST = "webHost" + /** The full URL (e.g. `https://example.com`) to toggle as a web favorite. */ + const val KEY_FAVORITE_URL = "favoriteUrl" + + /** A human-readable label for the favorited URL (typically the host). */ + const val KEY_FAVORITE_LABEL = "favoriteLabel" + /** Boolean: this sandbox surface is now foreground (true) or backgrounded (false). */ const val KEY_FOREGROUND = "foreground" diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index 1fef9c9700..cd0e294a13 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -34,6 +34,10 @@ Loads over Tor Loads over the open web + + Add to favorites + Remove from favorites + Couldn\'t load “%1$s” The publisher\'s servers may be offline, or you\'re not connected. You can try again.