Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280

This commit is contained in:
Claude
2026-06-27 23:00:26 +00:00
32 changed files with 1287 additions and 154 deletions
@@ -54,6 +54,9 @@ data class UiSettings(
val showProfileFollowersFeed: Boolean = true,
val dontShowOnchainPublicWarning: Boolean = false,
val suggestWorkoutsFromHealthConnect: BooleanType = BooleanType.ALWAYS,
val accentColor: AccentColorType = AccentColorType.PURPLE,
val fontFamily: FontFamilyType = FontFamilyType.SYSTEM,
val fontSize: FontSizeType = FontSizeType.NORMAL,
)
enum class ThemeType(
@@ -73,6 +76,68 @@ fun parseThemeType(code: Int?): ThemeType =
else -> ThemeType.SYSTEM
}
enum class AccentColorType(
val screenCode: Int,
val resourceId: Int,
) {
PURPLE(0, R.string.accent_color_purple),
BLUE(1, R.string.accent_color_blue),
GREEN(2, R.string.accent_color_green),
ORANGE(3, R.string.accent_color_orange),
RED(4, R.string.accent_color_red),
PINK(5, R.string.accent_color_pink),
}
fun parseAccentColorType(screenCode: Int): AccentColorType =
when (screenCode) {
AccentColorType.PURPLE.screenCode -> AccentColorType.PURPLE
AccentColorType.BLUE.screenCode -> AccentColorType.BLUE
AccentColorType.GREEN.screenCode -> AccentColorType.GREEN
AccentColorType.ORANGE.screenCode -> AccentColorType.ORANGE
AccentColorType.RED.screenCode -> AccentColorType.RED
AccentColorType.PINK.screenCode -> AccentColorType.PINK
else -> AccentColorType.PURPLE
}
enum class FontFamilyType(
val screenCode: Int,
val resourceId: Int,
) {
SYSTEM(0, R.string.font_family_system),
SANS_SERIF(1, R.string.font_family_sans_serif),
SERIF(2, R.string.font_family_serif),
MONOSPACE(3, R.string.font_family_monospace),
}
fun parseFontFamilyType(screenCode: Int): FontFamilyType =
when (screenCode) {
FontFamilyType.SYSTEM.screenCode -> FontFamilyType.SYSTEM
FontFamilyType.SANS_SERIF.screenCode -> FontFamilyType.SANS_SERIF
FontFamilyType.SERIF.screenCode -> FontFamilyType.SERIF
FontFamilyType.MONOSPACE.screenCode -> FontFamilyType.MONOSPACE
else -> FontFamilyType.SYSTEM
}
enum class FontSizeType(
val scale: Float,
val screenCode: Int,
val resourceId: Int,
) {
SMALL(0.85f, 0, R.string.font_size_small),
NORMAL(1.0f, 1, R.string.font_size_normal),
LARGE(1.15f, 2, R.string.font_size_large),
HUGE(1.3f, 3, R.string.font_size_huge),
}
fun parseFontSizeType(screenCode: Int): FontSizeType =
when (screenCode) {
FontSizeType.SMALL.screenCode -> FontSizeType.SMALL
FontSizeType.NORMAL.screenCode -> FontSizeType.NORMAL
FontSizeType.LARGE.screenCode -> FontSizeType.LARGE
FontSizeType.HUGE.screenCode -> FontSizeType.HUGE
else -> FontSizeType.NORMAL
}
enum class ConnectivityType(
val prefCode: Boolean?,
val screenCode: Int,
@@ -54,6 +54,9 @@ class UiSettingsFlow(
val showProfileFollowersFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val dontShowOnchainPublicWarning: MutableStateFlow<Boolean> = MutableStateFlow(false),
val suggestWorkoutsFromHealthConnect: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val accentColor: MutableStateFlow<AccentColorType> = MutableStateFlow(AccentColorType.PURPLE),
val fontFamily: MutableStateFlow<FontFamilyType> = MutableStateFlow(FontFamilyType.SYSTEM),
val fontSize: MutableStateFlow<FontSizeType> = MutableStateFlow(FontSizeType.NORMAL),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -82,6 +85,9 @@ class UiSettingsFlow(
showProfileFollowersFeed,
dontShowOnchainPublicWarning,
suggestWorkoutsFromHealthConnect,
accentColor,
fontFamily,
fontSize,
)
// emits at every change in any of the propertyes.
@@ -114,6 +120,9 @@ class UiSettingsFlow(
flows[22] as Boolean,
flows[23] as Boolean,
flows[24] as BooleanType,
flows[25] as AccentColorType,
flows[26] as FontFamilyType,
flows[27] as FontSizeType,
)
}
@@ -144,6 +153,9 @@ class UiSettingsFlow(
showProfileFollowersFeed.value,
dontShowOnchainPublicWarning.value,
suggestWorkoutsFromHealthConnect.value,
accentColor.value,
fontFamily.value,
fontSize.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -249,6 +261,18 @@ class UiSettingsFlow(
suggestWorkoutsFromHealthConnect.tryEmit(torSettings.suggestWorkoutsFromHealthConnect)
any = true
}
if (accentColor.value != torSettings.accentColor) {
accentColor.tryEmit(torSettings.accentColor)
any = true
}
if (fontFamily.value != torSettings.fontFamily) {
fontFamily.tryEmit(torSettings.fontFamily)
any = true
}
if (fontSize.value != torSettings.fontSize) {
fontSize.tryEmit(torSettings.fontSize)
any = true
}
return any
}
@@ -299,6 +323,9 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.showProfileFollowersFeed),
MutableStateFlow(uiSettings.dontShowOnchainPublicWarning),
MutableStateFlow(uiSettings.suggestWorkoutsFromHealthConnect),
MutableStateFlow(uiSettings.accentColor),
MutableStateFlow(uiSettings.fontFamily),
MutableStateFlow(uiSettings.fontSize),
)
}
}
@@ -31,9 +31,12 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.AccentColorType
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.FontFamilyType
import com.vitorpamplona.amethyst.model.FontSizeType
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.UiSettings
@@ -120,6 +123,9 @@ class UiSharedPreferences(
val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed")
val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning")
val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect")
val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color")
val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family")
val UI_FONT_SIZE = stringPreferencesKey("ui.font_size")
suspend fun uiPreferences(context: Context): UiSettings? =
try {
@@ -157,6 +163,9 @@ class UiSharedPreferences(
dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false,
suggestWorkoutsFromHealthConnect =
preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE,
fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM,
fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -206,6 +215,9 @@ class UiSharedPreferences(
preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed
preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning
preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name
preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name
preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name
preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -81,6 +81,16 @@ fun GetVideoController(
).onEach { state ->
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
// A warm-pool ExoPlayer can be handed back still carrying a prior
// PlaybackException (e.g. a decoder-init failure from an earlier acquire). The
// re-prepare below clears it before WatchPlaybackErrors ever attaches, so this
// is the only place the stale error — and its decoder/codec cause chain — is
// observable. Logged so a "Can't play this video" blink that self-heals can be
// attributed to warm-pool reuse rather than a genuinely undecodable stream.
state.controller.playerError?.let { err ->
Log.w(ERROR_LOG_TAG) { "Controller arrived carrying error for ${mediaItem.item.mediaId}: ${err.describe()}" }
}
// The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
// sets it to 0f when the player is acquired, so the controller arrives at 0f.
// Read first and only push an IPC if the value actually needs to change —
@@ -110,6 +120,11 @@ fun GetVideoController(
val targetMediaId = mediaItem.item.mediaId
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
if (needsLoad) {
// Cold load: a fresh decoder/codec instance gets allocated here. If a
// second controller for the same URI is still alive (see liveControllers
// in PlaybackServiceClient), this prepare() is where MediaCodec.start()
// can collide and fail.
Log.d("PlaybackService") { "Cold load (setMediaItem+prepare) for $targetMediaId" }
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
} else if (state.controller.playbackState == Player.STATE_IDLE) {
@@ -193,7 +193,12 @@ fun VideoView(
DisplayBlurHash(
blurhash,
null,
contentScale,
// The placeholder bitmap is decoded at the blurhash's DCT component-grid aspect
// (e.g. a 5x5 grid -> a square bitmap), NOT the real media shape. When `ratio` is
// known the Box is already sized to the true aspect, so crop the placeholder to
// fill it. Without this, FillWidth letterboxes the square placeholder inside the
// taller portrait box — the "square blurhash on a twice-as-tall space" bug.
if (ratio != null) ContentScale.Crop else contentScale,
if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier,
thumbhash = thumbhash,
)
@@ -30,8 +30,33 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.delay
// Debug tag for the playback-error lifecycle. Logs every appearance, clear, synthetic-stall raise
// and recovery so a transient decoder-init collision (which self-recovers on a later attempt) can
// be told apart from a genuinely undecodable stream in a field logcat. See WatchPlaybackErrors.
internal const val ERROR_LOG_TAG = "PlaybackError"
/**
* Flattens a [PlaybackException] into a single line: error code, message, and the full nested
* cause chain (e.g. `DecoderInitializationException <- MediaCodec.CodecException`). The cause
* chain is what distinguishes "format truly unsupported" from "decoder failed to start while a
* second controller held the codec" — both surface as the same top-level renderer error with
* `format_supported=YES`.
*
* Internal (not private) so [GetVideoController] can log the same detail at controller-acquire
* time — a warm-pool player can arrive already in ERROR and get re-prepared (cleared) before
* this watcher ever attaches, so the acquire site is the only place that error is observable.
*/
internal fun PlaybackException.describe(): String {
val causeChain =
generateSequence(cause) { it.cause }
.joinToString(" <- ") { "${it::class.simpleName}: ${it.message}" }
.ifEmpty { "none" }
return "code=$errorCodeName($errorCode) msg=$message causes=[$causeChain]"
}
// How often the decode-stall watchdog samples the controller's position/buffer.
private const val STALL_POLL_INTERVAL_MS = 1_000L
@@ -69,10 +94,18 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) {
// Prime from the controller's current state — a warm-pool player may already be in ERROR
// when we attach, in which case onPlayerErrorChanged will not fire again until prepare().
errorState.value = controller.playerError
controller.playerError?.let {
Log.w(ERROR_LOG_TAG) { "Primed with existing error on ${controller.currentMediaItem?.mediaId}: ${it.describe()}" }
}
val listener =
object : Player.Listener {
override fun onPlayerErrorChanged(error: PlaybackException?) {
if (error != null) {
Log.w(ERROR_LOG_TAG) { "Error raised on ${controller.currentMediaItem?.mediaId}: ${error.describe()}" }
} else if (errorState.value != null) {
Log.d(ERROR_LOG_TAG) { "Error cleared on ${controller.currentMediaItem?.mediaId}" }
}
errorState.value = error
}
@@ -81,7 +114,10 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) {
reason: Int,
) {
// A new item on a pooled player starts fresh; drop any error from the old one.
if (errorState.value != null) errorState.value = null
if (errorState.value != null) {
Log.d(ERROR_LOG_TAG) { "Error dropped on media transition (reason=$reason) -> ${mediaItem?.mediaId}" }
errorState.value = null
}
}
override fun onPlaybackStateChanged(state: Int) {
@@ -90,7 +126,10 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) {
// STATE_BUFFERING: the synthetic decode-stall error below is raised *while*
// buffering, and clearing on every buffering event would wipe it instantly.
if (state == Player.STATE_READY) {
if (errorState.value != null) errorState.value = null
if (errorState.value != null) {
Log.d(ERROR_LOG_TAG) { "Recovered (STATE_READY) on ${controller.currentMediaItem?.mediaId} — clearing overlay" }
errorState.value = null
}
}
}
}
@@ -141,6 +180,10 @@ private suspend fun watchForDecodeStall(
if (unproductiveSinceMs < 0) {
unproductiveSinceMs = now
} else if (now - unproductiveSinceMs >= STALL_TIMEOUT_MS && errorState.value == null) {
Log.w(ERROR_LOG_TAG) {
"Synthetic decode-stall after ${STALL_TIMEOUT_MS}ms fed-but-frozen " +
"(pos=$position buffered=${controller.bufferedPosition}) on ${controller.currentMediaItem?.mediaId}"
}
errorState.value =
PlaybackException(
"Video decoding stalled with a full buffer — likely an unsupported codec",
@@ -156,6 +199,7 @@ private suspend fun watchForDecodeStall(
// drop the stall overlay. Real decoder errors leave the player IDLE with a frozen
// playhead, so they never progress here and are left for the STATE_READY listener.
if (progressed && errorState.value != null) {
Log.d(ERROR_LOG_TAG) { "Playhead progressed to $position — clearing stall overlay on ${controller.currentMediaItem?.mediaId}" }
errorState.value = null
}
}
@@ -117,8 +117,20 @@ class ExoPlayerPool(
if (preferredMediaId != null) {
val warm = takeWarm(preferredMediaId)
if (warm != null) {
Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" }
return warm
// A warm player can error *after* it was pooled clean — its decoder dies
// asynchronously while paused (emulator surface reclaim, codec loss). releasePlayer
// can't catch that (the error appears post-release), so it's caught here at acquire:
// never hand a stale PlaybackException to a controller. Release the dead player and
// fall through to a clean cold/fresh one — a guaranteed setMediaItem+prepare ahead.
val error = warm.playerError
if (error != null) {
Log.d("PlaybackService") { "ExoPlayerPool discarding errored warm player: $preferredMediaId (${error.errorCodeName})" }
PcmTapRegistry.unregisterPlayer(warm)
warm.release()
} else {
Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" }
return warm
}
}
}
return coldPool.poll() ?: builder.build(context)
@@ -148,6 +160,19 @@ class ExoPlayerPool(
mutex.withLock {
if (player.isReleased) return@withLock
// A player that errored out (decoder-init failure, decode error) must never be
// returned to either pool. Kept warm, it hands the stale PlaybackException straight
// back to the next acquire of the same URI — the "Can't play this video" flash traced
// to warm-pool reuse. Its failed MediaCodec instance is also suspect. Drop it so the
// pool builds a clean replacement on the next miss.
val error = player.playerError
if (error != null) {
Log.d("PlaybackService") { "ExoPlayerPool dropping errored player: ${player.currentMediaItem?.mediaId} (${error.errorCodeName})" }
PcmTapRegistry.unregisterPlayer(player)
player.release()
return@withLock
}
val mediaId = player.currentMediaItem?.mediaId
if (mediaId != null && warmSlotsCap > 0) {
// Warm path: keep the player paused but loaded so a quick scroll-back to the
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.callbackFlow
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -46,6 +47,13 @@ object PlaybackServiceClient {
// video, each lingering for the 60s keep-alive afterwards.
val executorService: ExecutorService = Executors.newFixedThreadPool(4)
// Number of MediaControllers currently held alive (prepared and not yet released). Two
// controllers alive for the same videoUri at once is the signature of the decoder-init
// collision that surfaces as a transient "Can't play this video": the second one's
// MediaCodec.start() fails because the first still holds a codec instance. Logged on every
// prepare/release so the overlap is visible in a field logcat.
private val liveControllers = AtomicInteger(0)
fun shutdown() {
executorService.shutdown()
}
@@ -83,7 +91,7 @@ object PlaybackServiceClient {
.setConnectionHints(bundle)
.buildAsync()
Log.d("PlaybackService") { "Preparing Controller $id $videoUri" }
Log.d("PlaybackService") { "Preparing Controller $id (live=${liveControllers.incrementAndGet()}) $videoUri" }
controllerFuture.addListener(
{
@@ -108,7 +116,7 @@ object PlaybackServiceClient {
)
awaitClose {
Log.d("PlaybackService") { "Releasing Controller $id $videoUri" }
Log.d("PlaybackService") { "Releasing Controller $id (live=${liveControllers.decrementAndGet()}) $videoUri" }
try {
MediaController.releaseFuture(controllerFuture)
} catch (e: Exception) {
@@ -120,6 +120,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FavoriteAppManifestPreloader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.BrowseEmojiSetsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen
@@ -271,6 +272,9 @@ fun AppNavigation(
EmbeddedTabLayer(bottomBarItems.favoriteIds())
// Warm every pinned tab at startup so the first tap is instant (content already local).
EmbeddedTabPreloader(accountViewModel)
// Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips
// (an embed WebView's theme is fixed at construction, so it can't follow a live switch).
EmbeddedTabThemeWatcher()
}
}
}
@@ -451,7 +451,10 @@ fun RenderNutzapGallery(
Row(Modifier.fillMaxWidth()) {
Box(
modifier = WidthAuthorPictureModifier,
// Reuse the reaction galleries' icon column (55dp wide with a 5dp end
// inset) so the cashu glyph lines up with the like/boost icons above
// it, instead of sitting flush-right like the lightning ZappedIcon.
modifier = NotificationIconModifier,
) {
Icon(
imageVector = CustomHashTagIcons.Cashu,
@@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -70,10 +71,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions
import com.vitorpamplona.amethyst.commons.browser.SuggestedWebApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
@@ -83,12 +88,22 @@ import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.favoriteAppItems
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssemblerSubscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
import com.vitorpamplona.amethyst.commons.R as CommonsR
/** How many of the most recent history entries the idle browser home surfaces under "Recent". */
@@ -132,7 +147,10 @@ private fun BrowserLauncher(
var field by remember { mutableStateOf(TextFieldValue("")) }
// Favorites + visit history flattened into the neutral candidate shape the ranker consumes.
// Favorites + visit history + the hardcoded Discover apps, flattened into the neutral candidate shape
// the ranker consumes — so typing the omnibox finds a suggested app even before its first visit. The
// ranker dedupes by host, and favorites/history outscore a plain default, so a default that the user
// already pinned or visited collapses into that higher-ranked row instead of showing twice.
val candidates =
remember(apps, history) {
buildList {
@@ -148,19 +166,70 @@ private fun BrowserLauncher(
),
)
}
DefaultWebClients.list.forEach { add(OmniboxSuggestions.Candidate(it.app.url, it.app.label, isFavorite = false)) }
}
}
// Visited URLs, so the suggestion list can tell a Recent result from a Discover one (and only the
// former offers "Remove from history").
val historyUrls = remember(history) { history.mapTo(HashSet()) { it.url } }
// Discover nsites & napplets — the NIP-5A sites and NIP-5D apps published by the people the user
// follows. Same feed + follow-list filter the dedicated nSites/nApplets screens use (set those to
// "All Follows" for a pure follows list): the subscriptions pull manifests into LocalCache while the
// Browser tab is open, and we observe the addressable store and keep the matching authors'.
NsitesFilterAssemblerSubscription(accountViewModel)
NappletsFilterAssemblerSubscription(accountViewModel)
val nsiteNotes by remember {
Amethyst.instance.cache.observeNotes(Filter(kinds = listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND)))
}.collectAsStateWithLifecycle(emptyList())
val nappletNotes by remember {
Amethyst.instance.cache.observeNotes(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND)))
}.collectAsStateWithLifecycle(emptyList())
val nsiteFollows by accountViewModel.account.liveNsitesFollowLists.collectAsStateWithLifecycle()
val nsiteListName by accountViewModel.account.settings.defaultNsitesFollowList
.collectAsStateWithLifecycle()
val nappletFollows by accountViewModel.account.liveNappletsFollowLists.collectAsStateWithLifecycle()
val nappletListName by accountViewModel.account.settings.defaultNappletsFollowList
.collectAsStateWithLifecycle()
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
// Drop ones already pinned — they show under Favorites, not twice.
val favoriteCoordinates = remember(apps) { apps.filterIsInstance<FavoriteApp.NostrApp>().mapTo(HashSet()) { it.coordinate } }
val followedNsites =
remember(nsiteNotes, nsiteFollows, nsiteListName, myPubkey, favoriteCoordinates) {
nsiteNotes.toDiscoverApps(nsiteListName == TopFilter.Mine, myPubkey, nsiteFollows::matchAuthor, favoriteCoordinates)
}
val followedNapplets =
remember(nappletNotes, nappletFollows, nappletListName, myPubkey, favoriteCoordinates) {
nappletNotes.toDiscoverApps(nappletListName == TopFilter.Mine, myPubkey, nappletFollows::matchAuthor, favoriteCoordinates)
}
// What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the
// caret when collapsed, or the start of the highlighted suffix when a completion is showing).
val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length))
val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates) }
val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates, limit = 12) }
fun open(text: String) {
val target = OmniboxInput.resolve(text) ?: return
FavoriteAppLauncher.launchUrl(context, target.url, target.forceTor)
}
// Pin/unpin a plain web URL by its favorite id. Shared by the suggestion list and the Recent rows.
fun toggleFavorite(
url: String,
label: String,
) {
val id = "url:$url"
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis()))
}
}
// Inline autocomplete: when the user appends a character, offer the top host as selected ghost text so
// the next keystroke replaces it. On deletion or mid-string edits, leave the value untouched.
fun onValueChange(new: TextFieldValue) {
@@ -206,39 +275,29 @@ private fun BrowserLauncher(
SuggestionGrid(
suggestions = suggestions,
iconKeys = iconKeys,
historyUrls = historyUrls,
onOpen = { open(it.url) },
onToggleFavorite = { toggleFavorite(it.url, it.label) },
onRemoveFromHistory = { BrowserHistoryRegistry.remove(it) },
modifier = contentModifier,
)
apps.isEmpty() && history.isEmpty() ->
Box(
contentModifier.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.favorite_apps_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
val favoriteUrls = remember(apps) { apps.filterIsInstance<FavoriteApp.WebApp>().mapTo(HashSet()) { it.url } }
// Hardcoded starter web apps, minus any the user already pinned (those show under Favorites).
val suggested = remember(favoriteUrls) { DefaultWebClients.list.filter { it.app.url !in favoriteUrls } }
BrowserHome(
apps = apps,
history = history,
iconKeys = iconKeys,
favoriteUrls = favoriteUrls,
suggested = suggested,
nsites = followedNsites,
napplets = followedNapplets,
onOpenApp = { FavoriteAppLauncher.launch(context, it) },
onRemoveApp = { FavoriteAppsRegistry.remove(it.id) },
onAddApp = { FavoriteAppsRegistry.add(it) },
onOpenUrl = { open(it) },
onToggleRecentFavorite = { entry ->
val id = "url:" + entry.url
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
} else {
FavoriteAppsRegistry.add(
FavoriteApp.WebApp(entry.url, entry.title.ifBlank { entry.host }, System.currentTimeMillis()),
)
}
},
onToggleRecentFavorite = { entry -> toggleFavorite(entry.url, entry.title.ifBlank { entry.host }) },
onRemoveRecent = { BrowserHistoryRegistry.remove(it) },
modifier = contentModifier,
)
@@ -305,25 +364,50 @@ private fun OmniBar(
}
}
/** The typed-state body: ranked suggestions split into a highlighted Favorites group then Recent. */
/**
* The typed-state body: ranked suggestions split into a highlighted Favorites group, then Recent (visited
* sites), then Discover (the hardcoded web apps the user hasn't pinned or visited yet). Every row carries
* the same 3-dot menu as the idle Recent cards (pin/unpin; plus remove-from-history for visited sites).
*/
@Composable
private fun SuggestionGrid(
suggestions: List<OmniboxSuggestions.Suggestion>,
iconKeys: Set<String>,
historyUrls: Set<String>,
onOpen: (OmniboxSuggestions.Suggestion) -> Unit,
onToggleFavorite: (OmniboxSuggestions.Suggestion) -> Unit,
onRemoveFromHistory: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val favorites = suggestions.filter { it.isFavorite }
val others = suggestions.filterNot { it.isFavorite }
val recent = suggestions.filter { !it.isFavorite && it.url in historyUrls }
val discover = suggestions.filter { !it.isFavorite && it.url !in historyUrls }
fun LazyGridScope.section(
keyPrefix: String,
title: Int,
rows: List<OmniboxSuggestions.Suggestion>,
highlighted: Boolean,
) {
if (rows.isEmpty()) return
item(key = "h-$keyPrefix") { SectionHeader(stringResource(title)) }
items(rows, key = { "$keyPrefix:" + it.url }) { suggestion ->
SuggestionRow(
suggestion = suggestion,
iconKeys = iconKeys,
highlighted = highlighted,
removableFromHistory = suggestion.url in historyUrls,
onClick = { onOpen(suggestion) },
onToggleFavorite = { onToggleFavorite(suggestion) },
onRemoveFromHistory = { onRemoveFromHistory(suggestion.url) },
)
}
}
LazyVerticalGrid(columns = GridCells.Fixed(1), modifier = modifier) {
if (favorites.isNotEmpty()) {
item(key = "h-fav") { SectionHeader(stringResource(R.string.browser_favorites)) }
items(favorites, key = { "f:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = true) { onOpen(it) } }
}
if (others.isNotEmpty()) {
item(key = "h-rec") { SectionHeader(stringResource(R.string.favorite_app_recent)) }
items(others, key = { "o:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = false) { onOpen(it) } }
}
section("f", R.string.browser_favorites, favorites, highlighted = true)
section("o", R.string.favorite_app_recent, recent, highlighted = false)
section("s", R.string.browser_suggested, discover, highlighted = false)
}
}
@@ -332,15 +416,19 @@ private fun SuggestionRow(
suggestion: OmniboxSuggestions.Suggestion,
iconKeys: Set<String>,
highlighted: Boolean,
removableFromHistory: Boolean,
onClick: () -> Unit,
onToggleFavorite: () -> Unit,
onRemoveFromHistory: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(if (highlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else Color.Transparent)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SiteIcon(suggestion.host, suggestion.isFavorite, iconKeys, Modifier.size(24.dp))
@@ -363,6 +451,33 @@ private fun SuggestionRow(
)
}
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(MaterialSymbols.MoreVert, contentDescription = stringResource(R.string.browser_recent_options))
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringResource(if (suggestion.isFavorite) R.string.favorite_app_remove else R.string.favorite_app_add)) },
leadingIcon = {
Icon(if (suggestion.isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder, contentDescription = null)
},
onClick = {
menuOpen = false
onToggleFavorite()
},
)
if (removableFromHistory) {
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_recent_remove)) },
leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) },
onClick = {
menuOpen = false
onRemoveFromHistory()
},
)
}
}
}
}
}
@@ -373,8 +488,12 @@ private fun BrowserHome(
history: List<BrowserHistoryEntry>,
iconKeys: Set<String>,
favoriteUrls: Set<String>,
suggested: List<SuggestedWebApp>,
nsites: List<DiscoverNostrApp>,
napplets: List<DiscoverNostrApp>,
onOpenApp: (FavoriteApp) -> Unit,
onRemoveApp: (FavoriteApp) -> Unit,
onAddApp: (FavoriteApp) -> Unit,
onOpenUrl: (String) -> Unit,
onToggleRecentFavorite: (BrowserHistoryEntry) -> Unit,
onRemoveRecent: (String) -> Unit,
@@ -405,6 +524,180 @@ private fun BrowserHome(
)
}
}
if (nsites.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }, key = "h-nsite") { SectionHeader(stringResource(R.string.browser_discover_nsites)) }
items(nsites, span = { GridItemSpan(maxLineSpan) }, key = { "ns:" + it.app.coordinate }) { entry ->
NostrAppRow(entry, onClick = { onOpenApp(entry.app) }, onAddFavorite = { onAddApp(entry.app) })
}
}
if (napplets.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }, key = "h-napp") { SectionHeader(stringResource(R.string.browser_discover_napplets)) }
items(napplets, span = { GridItemSpan(maxLineSpan) }, key = { "np:" + it.app.coordinate }) { entry ->
NostrAppRow(entry, onClick = { onOpenApp(entry.app) }, onAddFavorite = { onAddApp(entry.app) })
}
}
if (suggested.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }, key = "h-sug") { SectionHeader(stringResource(R.string.browser_suggested)) }
items(suggested, span = { GridItemSpan(maxLineSpan) }, key = { "s:" + it.app.url }) { entry ->
SuggestedRow(
entry = entry,
iconKeys = iconKeys,
onClick = { onOpenApp(entry.app) },
onAddFavorite = { onAddApp(entry.app) },
)
}
}
}
}
/** A Discover row: the app's own icon, its name, and a one-line description, with a star to pin it. */
@Composable
private fun SuggestedRow(
entry: SuggestedWebApp,
iconKeys: Set<String>,
onClick: () -> Unit,
onAddFavorite: () -> Unit,
) {
val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(BrowserIconRegistry::iconModelFor) }
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FavoriteAppIcon(
app = entry.app,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
iconModel = iconModel,
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(
entry.app.label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
entry.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onAddFavorite) {
Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add))
}
}
}
/** A followed nsite/napplet, resolved into its launchable [app] plus the manifest [description]. */
private data class DiscoverNostrApp(
val app: FavoriteApp.NostrApp,
val description: String?,
)
/** How many followed nsites/napplets each Discover section surfaces, so the launcher stays tidy. */
private const val DISCOVER_NOSTR_LIMIT = 12
/**
* Keeps the [Note]s authored by the followed set (or by the user, in the "Mine" case — the shared
* matcher resolves Mine to all-follows, so it can't serve that case), drops ones already pinned, maps
* each to its launchable [DiscoverNostrApp], and caps the result.
*/
private fun List<Note>.toDiscoverApps(
mine: Boolean,
myPubkey: String,
matchAuthor: (String) -> Boolean,
excludeCoordinates: Set<String>,
): List<DiscoverNostrApp> =
asSequence()
.filter { note ->
val author = note.event?.pubKey ?: return@filter false
if (mine) author == myPubkey else matchAuthor(author)
}.mapNotNull { it.toDiscoverNostrApp() }
.filter { it.app.coordinate !in excludeCoordinates }
.take(DISCOVER_NOSTR_LIMIT)
.toList()
/** Resolve a cached nsite/napplet manifest note into a launchable favorite + its description, or null. */
private fun Note.toDiscoverNostrApp(): DiscoverNostrApp? {
val event = event ?: return null
val coordinate = FavoriteAppLauncher.coordinateOf(event)
val label: String
val description: String?
when (event) {
is RootSiteEvent -> {
label = event.title()?.ifBlank { null } ?: "Website"
description = event.description()
}
is NamedSiteEvent -> {
label = event.title()?.ifBlank { null } ?: event.identifier()
description = event.description()
}
is RootNappletEvent -> {
label = event.title()?.ifBlank { null } ?: "App"
description = event.description()
}
is NamedNappletEvent -> {
label = event.title()?.ifBlank { null } ?: event.identifier()
description = event.description()
}
else -> return null
}
return DiscoverNostrApp(FavoriteApp.NostrApp(coordinate, label, 0L), description)
}
/** A Discover row for a followed nsite/napplet: its manifest icon, name, and description, with a pin star. */
@Composable
private fun NostrAppRow(
discover: DiscoverNostrApp,
onClick: () -> Unit,
onAddFavorite: () -> Unit,
) {
val app = discover.app
val iconModel = rememberNappletIconModel(app.coordinate)
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FavoriteAppIcon(
app = app,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
iconModel = iconModel,
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(
app.label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!discover.description.isNullOrBlank()) {
Text(
discover.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(onClick = onAddFavorite) {
Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add))
}
}
}
@@ -113,8 +113,10 @@ private fun EmbeddedWebAppTab(
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
// Keyed on the theme epoch too: when the app theme flips, the warm session is torn down and this
// re-acquires a freshly-themed one (the embed WebView's theme is fixed at construction).
val controller =
remember(id) {
remember(id, EmbeddedTabHost.themeEpoch) {
EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor)
}
@@ -128,7 +130,7 @@ private fun EmbeddedWebAppTab(
// Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame.
val chrome =
remember(currentUrl, torOn, proxyAvailable, isFavorite) {
remember(currentUrl, torOn, proxyAvailable, isFavorite, controller) {
EmbeddedTabChrome(
title = hostLabel(currentUrl),
isSandbox = false,
@@ -56,6 +56,15 @@ object EmbeddedTabHost {
var activeId by mutableStateOf<String?>(null)
private set
/**
* Bumped whenever the app's resolved DARK/LIGHT theme flips (see [rebuildAllForTheme]). The embed
* WebView's theme is locked in at construction (`nightThemedContext`), so following a theme change
* means rebuilding the surface — the favorite screens and the preloader key their acquisition on this
* so they re-acquire a freshly-themed session instead of the stale warm one.
*/
var themeEpoch by mutableStateOf(0)
private set
/** Window-space bounds of the active tab's reserved content area. */
var contentBounds by mutableStateOf(Rect.Zero)
private set
@@ -158,4 +167,17 @@ object EmbeddedTabHost {
warm.clear()
copy.forEach { it.controller.teardown() }
}
/**
* The app theme changed: tear down every warm session (their WebViews are pinned to the old theme)
* and bump [themeEpoch] so the visible screen and the preloader re-acquire freshly-themed sessions.
* Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant its screen
* re-acquires — the user just sees the current tab reload in the new theme, not a blanked-out surface.
*/
fun rebuildAllForTheme() {
val copy = warm.toList()
warm.clear()
copy.forEach { it.controller.teardown() }
themeEpoch += 1
}
}
@@ -162,7 +162,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
},
) {
EmbeddedTabHost.sessions.forEach { session ->
key(session.id) {
// Key on the controller too: a theme rebuild replaces the controller under the same id, and
// the new one needs a fresh SandboxedSdkView (the factory below attaches the surface once).
key(session.id, session.controller) {
val active = session.id == activeId
LaunchedEffect(active) {
@@ -76,7 +76,9 @@ fun EmbeddedTabPreloader(accountViewModel: AccountViewModel) {
}
}
LaunchedEffect(favoriteIds, backgroundColor) {
// Re-warms after a theme flip: [rebuildAllForTheme] tears down the warm sessions and bumps the epoch,
// so this sweep re-acquires them in the new theme (keying on the epoch also orders it after the teardown).
LaunchedEffect(favoriteIds, backgroundColor, EmbeddedTabHost.themeEpoch) {
if (favoriteIds.isEmpty()) return@LaunchedEffect
// Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads
// the bare Tor default and would route a site the user pinned to the open web through Tor (or stall
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.ThemeType
/**
* Keeps the warm embedded tabs in sync with the app's DARK/LIGHT theme. An embed WebView resolves its
* theme from the context it's built with (`nightThemedContext`), once, at construction — a runtime config
* change does NOT re-flip the renderer — so the only way a live theme switch reaches an already-running
* surface is to rebuild it. This watches the resolved theme and, on an actual flip, asks
* [EmbeddedTabHost] to tear down + re-acquire every session in the new theme.
*
* Mount once next to [EmbeddedTabLayer]/[EmbeddedTabPreloader]. Draws nothing.
*/
@RequiresApi(Build.VERSION_CODES.R)
@Composable
fun EmbeddedTabThemeWatcher() {
val theme by Amethyst.instance.uiPrefs.value.theme
.collectAsStateWithLifecycle()
// SYSTEM resolves against the device night mode, so a scheduled/auto device flip also rebuilds.
val systemDark = isSystemInDarkTheme()
val resolvedDark =
when (theme) {
ThemeType.DARK -> true
ThemeType.LIGHT -> false
ThemeType.SYSTEM -> systemDark
}
// Holds the theme the warm surfaces were last built in; a mismatch (only after a real flip — the
// first composition seeds it equal) triggers exactly one rebuild.
val applied = remember { mutableStateOf(resolvedDark) }
LaunchedEffect(resolvedDark) {
if (applied.value != resolvedDark) {
applied.value = resolvedDark
EmbeddedTabHost.rebuildAllForTheme()
}
}
}
@@ -112,8 +112,9 @@ private fun EmbeddedNostrAppTab(
// Matches FavoriteApp.NostrApp.id, so warm-keep membership lines up with the bottom-bar favorites.
val id = "nostr:$coordinate"
// Mint the verified launch params (a fresh token per resolve); null until the event loads.
val params = remember(coordinate) { FavoriteAppLauncher.embedParams(context, coordinate) }
// Mint the verified launch params (a fresh token per resolve); null until the event loads. Re-minted
// on a theme flip (the params carry the resolved theme into the sandbox host's WebView).
val params = remember(coordinate, EmbeddedTabHost.themeEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) }
if (params == null) {
UnavailableTab(coordinate, accountViewModel, nav)
return
@@ -133,7 +134,7 @@ private fun EmbeddedNostrAppTab(
val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } }
val controller =
remember(id) {
remember(id, EmbeddedTabHost.themeEpoch) {
EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor)
}
@@ -147,7 +148,7 @@ private fun EmbeddedNostrAppTab(
// Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame.
val chrome =
remember(title, coordinate, isFavorite) {
remember(title, coordinate, isFavorite, controller) {
EmbeddedTabChrome(
title = title.ifBlank { coordinate },
isSandbox = true,
@@ -193,7 +193,12 @@ private fun SearchBar(
}
}
Column(modifier = Modifier.statusBarsPadding()) {
Column(
modifier =
Modifier
.background(MaterialTheme.colorScheme.surface)
.statusBarsPadding(),
) {
SearchTextField(searchBarViewModel, Modifier)
// Inline Namecoin lookup feedback for the global search field.
// Mirrors the wiring in OnchainZapSendDialog: the local prefix
@@ -48,15 +48,19 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AccentColorType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.FontFamilyType
import com.vitorpamplona.amethyst.model.FontSizeType
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.parseAccentColorType
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseFeatureSetType
import com.vitorpamplona.amethyst.model.parseGalleryType
import com.vitorpamplona.amethyst.model.parseFontFamilyType
import com.vitorpamplona.amethyst.model.parseFontSizeType
import com.vitorpamplona.amethyst.model.parseThemeType
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
@@ -113,6 +117,9 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
) {
ShowLanguageChoice(sharedPrefs)
ShowThemeChoice(sharedPrefs)
ShowAccentColorChoice(sharedPrefs)
ShowFontFamilyChoice(sharedPrefs)
ShowFontSizeChoice(sharedPrefs)
ShowImagePreviewChoice(sharedPrefs)
ShowVideoPlaybackChoice(sharedPrefs)
AutoplayVideosChoice(sharedPrefs)
@@ -120,7 +127,6 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
ShowProfilePictureChoice(sharedPrefs)
ImmersiveScrollingChoice(sharedPrefs)
FeatureSetChoice(sharedPrefs)
GalleryChoice(sharedPrefs)
}
}
@@ -218,6 +224,74 @@ fun ShowThemeChoice(sharedPrefs: UiSettingsFlow) {
}
}
@Composable
fun ShowAccentColorChoice(sharedPrefs: UiSettingsFlow) {
val accentOptions =
persistentListOf(
TitleExplainer(stringRes(AccentColorType.PURPLE.resourceId)),
TitleExplainer(stringRes(AccentColorType.BLUE.resourceId)),
TitleExplainer(stringRes(AccentColorType.GREEN.resourceId)),
TitleExplainer(stringRes(AccentColorType.ORANGE.resourceId)),
TitleExplainer(stringRes(AccentColorType.RED.resourceId)),
TitleExplainer(stringRes(AccentColorType.PINK.resourceId)),
)
val accentIndex by sharedPrefs.accentColor.collectAsState()
SettingsRow(
R.string.accent_color,
R.string.accent_color_description,
accentOptions,
accentIndex.screenCode,
) {
sharedPrefs.accentColor.tryEmit(parseAccentColorType(it))
}
}
@Composable
fun ShowFontFamilyChoice(sharedPrefs: UiSettingsFlow) {
val fontOptions =
persistentListOf(
TitleExplainer(stringRes(FontFamilyType.SYSTEM.resourceId)),
TitleExplainer(stringRes(FontFamilyType.SANS_SERIF.resourceId)),
TitleExplainer(stringRes(FontFamilyType.SERIF.resourceId)),
TitleExplainer(stringRes(FontFamilyType.MONOSPACE.resourceId)),
)
val fontIndex by sharedPrefs.fontFamily.collectAsState()
SettingsRow(
R.string.font_family,
R.string.font_family_description,
fontOptions,
fontIndex.screenCode,
) {
sharedPrefs.fontFamily.tryEmit(parseFontFamilyType(it))
}
}
@Composable
fun ShowFontSizeChoice(sharedPrefs: UiSettingsFlow) {
val fontSizeOptions =
persistentListOf(
TitleExplainer(stringRes(FontSizeType.SMALL.resourceId)),
TitleExplainer(stringRes(FontSizeType.NORMAL.resourceId)),
TitleExplainer(stringRes(FontSizeType.LARGE.resourceId)),
TitleExplainer(stringRes(FontSizeType.HUGE.resourceId)),
)
val fontSizeIndex by sharedPrefs.fontSize.collectAsState()
SettingsRow(
R.string.font_size,
R.string.font_size_description,
fontSizeOptions,
fontSizeIndex.screenCode,
) {
sharedPrefs.fontSize.tryEmit(parseFontSizeType(it))
}
}
@Composable
fun ShowImagePreviewChoice(sharedPrefs: UiSettingsFlow) {
val connectivityBasedOptions =
@@ -363,26 +437,6 @@ fun FeatureSetChoice(sharedPrefs: UiSettingsFlow) {
}
}
@Composable
fun GalleryChoice(sharedPrefs: UiSettingsFlow) {
val galleryItems =
persistentListOf(
TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)),
TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)),
)
val galleryIndex by sharedPrefs.gallerySet.collectAsState()
SettingsRow(
R.string.gallery_style,
R.string.gallery_style_description,
galleryItems,
galleryIndex.screenCode,
) {
sharedPrefs.gallerySet.tryEmit(parseGalleryType(it))
}
}
@Composable
fun SettingsRow(
name: Int,
@@ -43,6 +43,10 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.parseGalleryType
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
@@ -51,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.persistentListOf
@Preview
@Composable
@@ -129,11 +134,36 @@ fun ProfileUiSettingsContent(accountViewModel: AccountViewModel) {
checked = showFollowers,
onCheckedChange = { ui.showProfileFollowersFeed.tryEmit(it) },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
Column(modifier = Modifier.padding(vertical = 12.dp, horizontal = Size20dp)) {
GalleryChoice(ui)
}
Spacer(Modifier.height(16.dp))
}
}
@Composable
fun GalleryChoice(sharedPrefs: UiSettingsFlow) {
val galleryItems =
persistentListOf(
TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)),
TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)),
)
val galleryIndex by sharedPrefs.gallerySet.collectAsStateWithLifecycle()
SettingsRow(
R.string.gallery_style,
R.string.gallery_style_description,
galleryItems,
galleryIndex.screenCode,
) {
sharedPrefs.gallerySet.tryEmit(parseGalleryType(it))
}
}
@Composable
private fun ProfileUiSwitchRow(
title: String,
@@ -36,6 +36,19 @@ val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
// Accent palette options selected through Settings -> Accent Color.
// Each accent ships a brighter variant for the dark theme and a deeper variant for the light theme.
val AccentBlueDark = Color(0xFF82B1FF)
val AccentBlueLight = Color(0xFF1565C0)
val AccentGreenDark = Color(0xFF80CBC4)
val AccentGreenLight = Color(0xFF2E7D32)
val AccentOrangeDark = Color(0xFFFFB74D)
val AccentOrangeLight = Color(0xFFE65100)
val AccentRedDark = Color(0xFFEF9A9A)
val AccentRedLight = Color(0xFFC62828)
val AccentPinkDark = Color(0xFFF48FB1)
val AccentPinkLight = Color(0xFFAD1457)
val BitcoinOrange = Color(0xFFF7931A)
val RoyalBlue = Color(0xFF4169E1)
@@ -31,12 +31,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -45,11 +48,13 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
@@ -62,48 +67,58 @@ import com.patrykandpatrick.vico.compose.common.VicoTheme
import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
import com.vitorpamplona.amethyst.model.AccentColorType
import com.vitorpamplona.amethyst.model.FontFamilyType
import com.vitorpamplona.amethyst.model.FontSizeType
import com.vitorpamplona.amethyst.model.ThemeType
private val DarkColorPalette =
// The accent color (primary/secondary/tertiary) is user-selectable in Settings -> Accent Color.
// Purple keeps the original Amethyst look (purple primary + teal secondary). Every other accent
// uses its single hue across primary and secondary for a cohesive single-color theme.
private fun accentPrimary(
accent: AccentColorType,
dark: Boolean,
): Color =
when (accent) {
AccentColorType.PURPLE -> if (dark) Purple200 else Purple500
AccentColorType.BLUE -> if (dark) AccentBlueDark else AccentBlueLight
AccentColorType.GREEN -> if (dark) AccentGreenDark else AccentGreenLight
AccentColorType.ORANGE -> if (dark) AccentOrangeDark else AccentOrangeLight
AccentColorType.RED -> if (dark) AccentRedDark else AccentRedLight
AccentColorType.PINK -> if (dark) AccentPinkDark else AccentPinkLight
}
private fun accentSecondary(
accent: AccentColorType,
dark: Boolean,
): Color = if (accent == AccentColorType.PURPLE) Teal200 else accentPrimary(accent, dark)
private fun darkColors(accent: AccentColorType) =
darkColorScheme(
primary = Purple200,
secondary = Teal200,
tertiary = Teal200,
primary = accentPrimary(accent, dark = true),
secondary = accentSecondary(accent, dark = true),
tertiary = accentSecondary(accent, dark = true),
background = Color.Black,
surface = Color.Black,
surfaceDim = Color.Black,
surfaceVariant = Color(red = 29, green = 26, blue = 34),
)
private val LightColorPalette =
private fun lightColors(accent: AccentColorType) =
lightColorScheme(
primary = Purple500,
secondary = Teal200,
tertiary = Teal200,
primary = accentPrimary(accent, dark = false),
secondary = accentSecondary(accent, dark = false),
tertiary = accentSecondary(accent, dark = false),
surfaceContainerHighest = Color(red = 236, green = 230, blue = 240),
surfaceVariant = Color(red = 250, green = 245, blue = 252),
)
private val DarkNewItemBackground = DarkColorPalette.primary.copy(0.12f)
private val LightNewItemBackground = LightColorPalette.primary.copy(0.12f)
private val DarkColorPalette = darkColors(AccentColorType.PURPLE)
private val LightColorPalette = lightColors(AccentColorType.PURPLE)
private val DarkTransparentBackground = DarkColorPalette.background.copy(0.32f)
private val LightTransparentBackground = LightColorPalette.background.copy(0.32f)
private val DarkSelectedNote = DarkNewItemBackground.compositeOver(DarkColorPalette.background)
private val LightSelectedNote = LightNewItemBackground.compositeOver(LightColorPalette.background)
private val DarkButtonBackground =
DarkColorPalette.primary.copy(alpha = 0.32f).compositeOver(DarkColorPalette.background)
private val LightButtonBackground =
LightColorPalette.primary.copy(alpha = 0.32f).compositeOver(LightColorPalette.background)
private val DarkLessImportantLink = DarkColorPalette.primary.copy(alpha = 0.52f)
private val LightLessImportantLink = LightColorPalette.primary.copy(alpha = 0.52f)
private val DarkMediumImportantLink = DarkColorPalette.primary.copy(alpha = 0.32f)
private val LightMediumImportantLink = LightColorPalette.primary.copy(alpha = 0.32f)
private val DarkGrayText = DarkColorPalette.onSurface.copy(alpha = 0.52f)
private val LightGrayText = LightColorPalette.onSurface.copy(alpha = 0.52f)
@@ -407,26 +422,32 @@ val MarkDownStyleOnLight =
),
)
// Compared against the dark palette's background instead of a fixed primary so the check keeps
// working when the user picks a non-purple accent (accent only changes primary/secondary, never
// background). Kept as a single reference comparison because this getter fans out to hundreds of
// themed-color call sites on hot rendering paths — luminance()/etc. would add real per-frame cost.
val ColorScheme.isLight: Boolean
get() = primary == Purple500
get() = background != Color.Black
// The accent-derived tints below are computed from the live scheme's primary so they follow
// the selected accent color. Color is an inline value class, so these copies don't allocate.
val ColorScheme.newItemBackgroundColor: Color
get() = if (isLight) LightNewItemBackground else DarkNewItemBackground
get() = primary.copy(alpha = 0.12f)
val ColorScheme.transparentBackground: Color
get() = if (isLight) LightTransparentBackground else DarkTransparentBackground
val ColorScheme.selectedNote: Color
get() = if (isLight) LightSelectedNote else DarkSelectedNote
get() = primary.copy(alpha = 0.12f).compositeOver(background)
val ColorScheme.secondaryButtonBackground: Color
get() = if (isLight) LightButtonBackground else DarkButtonBackground
get() = primary.copy(alpha = 0.32f).compositeOver(background)
val ColorScheme.lessImportantLink: Color
get() = if (isLight) LightLessImportantLink else DarkLessImportantLink
get() = primary.copy(alpha = 0.52f)
val ColorScheme.mediumImportanceLink: Color
get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink
get() = primary.copy(alpha = 0.32f)
val ColorScheme.placeholderText: Color
get() = if (isLight) LightPlaceholderText else DarkPlaceholderText
@@ -562,15 +583,21 @@ val ColorScheme.chartStyle: VicoTheme
@Composable
fun AmethystTheme(content: @Composable () -> Unit) {
val theme by Amethyst.instance.uiPrefs.value.theme
.collectAsStateWithLifecycle()
val uiPrefs = Amethyst.instance.uiPrefs.value
val theme by uiPrefs.theme.collectAsStateWithLifecycle()
val accentColor by uiPrefs.accentColor.collectAsStateWithLifecycle()
val fontFamily by uiPrefs.fontFamily.collectAsStateWithLifecycle()
val fontSize by uiPrefs.fontSize.collectAsStateWithLifecycle()
AmethystTheme(theme, content)
AmethystTheme(theme, accentColor, fontFamily, fontSize, content)
}
@Composable
fun AmethystTheme(
prefTheme: ThemeType,
accentColor: AccentColorType = AccentColorType.PURPLE,
fontFamily: FontFamilyType = FontFamilyType.SYSTEM,
fontSize: FontSizeType = FontSizeType.NORMAL,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
@@ -592,13 +619,33 @@ fun AmethystTheme(
isSystemInDarkTheme()
}
}
val colors = if (darkTheme) DarkColorPalette else LightColorPalette
val colors =
remember(darkTheme, accentColor) {
if (darkTheme) darkColors(accentColor) else lightColors(accentColor)
}
val resolvedFontFamily = remember(fontFamily) { fontFamily.toFontFamily() }
val typography = remember(fontFamily) { Typography.withFontFamily(resolvedFontFamily) }
val density = LocalDensity.current
val scaledDensity =
remember(density, fontSize) {
Density(density.density, density.fontScale * fontSize.scale)
}
MaterialTheme(
colorScheme = colors,
typography = Typography,
typography = typography,
shapes = Shapes,
content = { ProvideMaterialSymbols(content = content) },
content = {
ProvideMaterialSymbols {
CompositionLocalProvider(
LocalDensity provides scaledDensity,
LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)),
content = content,
)
}
},
)
val view = LocalView.current
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.halilibo.richtext.ui.HeadingStyle
import com.vitorpamplona.amethyst.model.FontFamilyType
// Set of Material typography styles to start with
val Typography =
@@ -52,6 +53,39 @@ val Typography =
*/
)
// Maps the user-selected font preference to a Compose [FontFamily].
// SYSTEM returns null so the platform default is used unchanged.
fun FontFamilyType.toFontFamily(): FontFamily? =
when (this) {
FontFamilyType.SYSTEM -> null
FontFamilyType.SANS_SERIF -> FontFamily.SansSerif
FontFamilyType.SERIF -> FontFamily.Serif
FontFamilyType.MONOSPACE -> FontFamily.Monospace
}
// Applies the chosen [FontFamily] to every text style so Material components pick it up too.
// A null family leaves the typography untouched (platform default).
fun Typography.withFontFamily(fontFamily: FontFamily?): Typography {
if (fontFamily == null) return this
return copy(
displayLarge = displayLarge.copy(fontFamily = fontFamily),
displayMedium = displayMedium.copy(fontFamily = fontFamily),
displaySmall = displaySmall.copy(fontFamily = fontFamily),
headlineLarge = headlineLarge.copy(fontFamily = fontFamily),
headlineMedium = headlineMedium.copy(fontFamily = fontFamily),
headlineSmall = headlineSmall.copy(fontFamily = fontFamily),
titleLarge = titleLarge.copy(fontFamily = fontFamily),
titleMedium = titleMedium.copy(fontFamily = fontFamily),
titleSmall = titleSmall.copy(fontFamily = fontFamily),
bodyLarge = bodyLarge.copy(fontFamily = fontFamily),
bodyMedium = bodyMedium.copy(fontFamily = fontFamily),
bodySmall = bodySmall.copy(fontFamily = fontFamily),
labelLarge = labelLarge.copy(fontFamily = fontFamily),
labelMedium = labelMedium.copy(fontFamily = fontFamily),
labelSmall = labelSmall.copy(fontFamily = fontFamily),
)
}
val Font4SP = 4.sp
val Font6SP = 6.sp
val Font8SP = 8.sp
@@ -659,6 +659,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="browser_go">Otwórz</string>
<string name="browser_clear">Wyczyść</string>
<string name="browser_favorites">Ulubione</string>
<string name="browser_suggested">Odkryj apki webowe</string>
<string name="browser_discover_nsites">Strony osób, które obserwujesz</string>
<string name="browser_discover_napplets">Aplikacje od osób, które obserwujesz</string>
<string name="browser_recent_options">Opcje</string>
<string name="browser_recent_remove">Usuń z historii</string>
<string name="favorite_apps">Ulubione aplikacje</string>
@@ -1481,6 +1484,26 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="wallet_connect">Podłącz portfel</string>
<string name="language">Język</string>
<string name="theme">Motyw</string>
<string name="accent_color">Kolor akcentujący</string>
<string name="accent_color_description">Główny kolor używany przez przyciski i linki</string>
<string name="accent_color_purple">Fioletowy</string>
<string name="accent_color_blue">Niebieski</string>
<string name="accent_color_green">Zielony</string>
<string name="accent_color_orange">Pomarańczowy</string>
<string name="accent_color_red">Czerwony</string>
<string name="accent_color_pink">Różowy</string>
<string name="font_family">Czcionka</string>
<string name="font_family_description">Czcionka używana w całej aplikacji</string>
<string name="font_family_system">Domyślna</string>
<string name="font_family_sans_serif">Sans Serif</string>
<string name="font_family_serif">Serif</string>
<string name="font_family_monospace">Monospace</string>
<string name="font_size">Rozmiar czcionki</string>
<string name="font_size_description">Dostosuj rozmiar tekstu w całej aplikacji</string>
<string name="font_size_small">Mały</string>
<string name="font_size_normal">Normalny</string>
<string name="font_size_large">Duży</string>
<string name="font_size_huge">Wielki</string>
<string name="automatically_load_images_gifs">Podgląd obrazu</string>
<string name="automatically_play_videos">Odtwarzanie filmów</string>
<string name="autoplay_videos">Autoodtwarzanie filmów</string>
@@ -1803,6 +1826,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="cashu_remove_mint">Usuń Minta</string>
<string name="cashu_add_mint">Dodaj mint</string>
<string name="cashu_history">Historia</string>
<string name="cashu_wallet_autosaves">Twój portfel zapisuje dane automatycznie w miarę dodawania lub usuwania mintów. Klucz Nutzap jest generowany automatycznie przy pierwszym dodaniu minta.</string>
<string name="cashu_wallet_saving">Zapisywanie…</string>
<string name="cashu_p2pk_section">Klucz Nutzap (zaawansowany)</string>
<string name="cashu_p2pk_explainer">Oddzielny klucz prywatny służący wyłącznie do odbierania NIP-61 Nutzaps. Nie jest to klucz tożsamości Nostr.</string>
<string name="cashu_p2pk_autogen">Wygeneruj nowy klucz</string>
@@ -2306,6 +2331,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="forked_from">Sklonowany z</string>
<string name="git_web_address">Strona internetowa:</string>
<string name="git_clone_address">Klonuj:</string>
<string name="git_branch">Gałąź</string>
<string name="git_commit">Commit</string>
<string name="git_merge_base">Scal gałąź główną</string>
<string name="git_pr_update_description">Zaktualizowano pull request, uwzględniając nowy commit.</string>
<string name="git_status_open">Otwarte</string>
<string name="git_status_merged">Połączone</string>
<string name="git_status_closed">Zamknięte</string>
@@ -2313,11 +2342,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="git_repo_tab_overview">Przegląd</string>
<string name="git_repo_tab_issues">Problemy</string>
<string name="git_repo_tab_patches">Łaty &amp; PRs</string>
<string name="git_repo_filter_open">Otwórz</string>
<string name="git_repo_filter_closed">Zamknięty &amp; Rozwiązany</string>
<string name="git_untitled">Bez tytułu</string>
<string name="git_repo_section_about">O programie</string>
<string name="git_repo_section_links">Linki</string>
<string name="git_repo_section_maintainers">Opiekunowie</string>
<string name="git_repo_section_topics">Tematy</string>
<string name="git_repo_personal_fork">Osobisty fork</string>
<string name="git_repositories">Repozytoria Git</string>
<string name="nsite_title">Statyczna Witryna: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Uprawnienia:</string>
@@ -2608,6 +2641,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="kind_git_patch">Łatka Git</string>
<string name="kind_git_repo">Repozytorium Git</string>
<string name="kind_git_reply">Odpowiedź Git</string>
<string name="kind_git_pr">Wniosek o zmianę</string>
<string name="kind_git_pr_update">Aktualizacja PR</string>
<string name="kind_zap_goals">Cele Zap-a</string>
<string name="kind_hashtag_follows">Obserwowane hashtagi</string>
<string name="kind_highlights">Najważniejsze informacje</string>
@@ -2342,6 +2342,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="git_repo_section_maintainers">Vzdrževalci</string>
<string name="git_repo_section_topics">Teme</string>
<string name="git_repo_personal_fork">Osebni fork</string>
<string name="git_repositories">Git repozitoriji</string>
<string name="nsite_title">Statična spletna stran: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Dovoljenja:</string>
+23
View File
@@ -675,6 +675,9 @@
<string name="browser_go">Open</string>
<string name="browser_clear">Clear</string>
<string name="browser_favorites">Favorites</string>
<string name="browser_suggested">Discover web apps</string>
<string name="browser_discover_nsites">Sites from people you follow</string>
<string name="browser_discover_napplets">Apps from people you follow</string>
<string name="browser_recent_options">Options</string>
<string name="browser_recent_remove">Remove from history</string>
<string name="favorite_apps">Web apps</string>
@@ -1621,6 +1624,26 @@
<string name="wallet_connect">Wallet Connect</string>
<string name="language">Language</string>
<string name="theme">Theme</string>
<string name="accent_color">Accent Color</string>
<string name="accent_color_description">Main color used across buttons and links</string>
<string name="accent_color_purple">Purple</string>
<string name="accent_color_blue">Blue</string>
<string name="accent_color_green">Green</string>
<string name="accent_color_orange">Orange</string>
<string name="accent_color_red">Red</string>
<string name="accent_color_pink">Pink</string>
<string name="font_family">Font</string>
<string name="font_family_description">Typeface used throughout the app</string>
<string name="font_family_system">System Default</string>
<string name="font_family_sans_serif">Sans Serif</string>
<string name="font_family_serif">Serif</string>
<string name="font_family_monospace">Monospace</string>
<string name="font_size">Font Size</string>
<string name="font_size_description">Scale the text size across the app</string>
<string name="font_size_small">Small</string>
<string name="font_size_normal">Normal</string>
<string name="font_size_large">Large</string>
<string name="font_size_huge">Huge</string>
<string name="automatically_load_images_gifs">Image Preview</string>
<string name="automatically_play_videos">Video Playback</string>
<string name="autoplay_videos">Autoplay Videos</string>
@@ -0,0 +1,151 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.browser
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
/** One suggested web app: the launchable [app] plus a short [description] of what it does. */
@Immutable
data class SuggestedWebApp(
val app: FavoriteApp.WebApp,
val description: String,
)
/**
* The Nostr **web apps** offered as starting points in the browser launcher when the user hasn't
* pinned or visited anything of their own yet. They are shown under a "Discover" section below Recent,
* so they're discoverable without ever getting in the way of the user's own favorites/history.
*
* The list is drawn from the [nostrapps.com](https://nostrapps.com) directory (cross-checked against
* [awesome-nostr](https://github.com/aljazceru/awesome-nostr)) — every entry that ships a
* browser-openable web version grouped here by what it does. Entries that are browser *extensions* or
* signer-only tools (nos2x, Nostrame, ) are intentionally left out: they aren't something you open in
* an in-app browser. URLs are the apps' own canonical domains.
*
* Each entry carries:
* - a short curated [SuggestedWebApp.description] (a trimmed version of the app's own meta description)
* shown as the row subtitle, since these are apps the user likely hasn't seen before;
* - the app's **own** logo as [FavoriteApp.iconUrl] (the PNG/SVG declared in its
* `<link rel="apple-touch-icon">` / `<link rel="icon">`), so it matches the favicon look of the
* Favorites/Recent rows without routing through any third-party favicon service. Only PNG/SVG are
* used Coil has no ICO decoder so apps whose only icon is a `.ico` (and a few that couldn't be
* resolved) are left icon-less and fall back to the globe glyph until their real favicon is captured
* the normal way on first visit.
*
* The [app]s are plain [FavoriteApp.WebApp] entries, so tapping one opens it like any other web
* favorite and the user can star it to pin it for real. They are **not** persisted as favorites: the
* list is hardcoded, device-local, and never becomes account state.
*/
object DefaultWebClients {
val list: List<SuggestedWebApp> =
buildList {
// Social / microblogging clients
webApp("Primal", "https://primal.net", "All-in-one client with a built-in wallet", "https://primal.net/assets/apple-touch-icon-a536f430.png")
webApp("Coracle", "https://coracle.social", "Relay-savvy client for regular people", "https://coracle.social/icons/apple-touch-icon-76x76.png")
webApp("Snort", "https://snort.social", "Fast, feature-packed social client", "https://snort.social/img/apple-touch-icon.png")
webApp("noStrudel", "https://nostrudel.ninja", "Power-user client for exploring Nostr", "https://nostrudel.ninja/apple-touch-icon.png")
webApp("Iris", "https://iris.to", "Simple, fast social client", "https://iris.to/img/apple-touch-icon.png")
webApp("Nostter", "https://nostter.app", "Lightweight web social client", "https://nostter.app/apple-touch-icon.png")
webApp("Jumble", "https://jumble.social", "Explore feeds relay by relay", "https://jumble.social/favicon.svg")
webApp("Nostria", "https://nostria.app", "Social without the noise", "https://nostria.app/icons/icon-192x192-maskable.png")
webApp("Nosotros", "https://nosotros.app", "A weirdly fast social client", "https://nosotros.app/apple-touch-icon-144x144.png")
webApp("lumilumi", "https://lumilumi.app", "Lightweight Nostr client", "https://lumilumi.app/apple-touch-icon-180x180.png")
webApp("Phoenix", "https://phoenix.social", "Snort-based social client", "https://phoenix.social/img/apple-touch-icon.png")
webApp("Shosho", "https://shosho.live", "Live-streaming marketplace", "https://shosho.live/apple-touch-icon.png")
webApp("ants", "https://ants.sh", "Advanced Nostr text search", "https://ants.sh/apple-touch-icon.png")
webApp("YakiHonne", "https://yakihonne.com", "Decentralized media & long-form")
webApp("Ditto", "https://ditto.pub", "Your content, your vibe, your rules", "https://ditto.pub/apple-touch-icon.png")
webApp("x21", "https://x21.social", "Relay feed explorer", "https://x21.social/apple-touch-icon.png?v=4")
webApp("Ghostr", "https://ghostr.org", "Draft & delegated publishing", "https://ghostr.org/favicon/apple-touch-icon.png")
webApp("Mutable", "https://mutable.top", "Your mute list manager", "https://mutable.top/mutable_logo.svg")
// Reading / long-form / feeds
webApp("Habla", "https://habla.news", "Long-form articles & blogs")
webApp("Highlighter", "https://highlighter.com", "Articles, highlights & communities", "https://highlighter.com/apple-touch-icon-180x180.png")
webApp("Boris", "https://readwithboris.com", "Distraction-free reading & highlights", "https://readwithboris.com/apple-touch-icon.png")
webApp("Noflux", "https://noflux.nostr.technology", "RSS-style feed reader")
// Communities / chat
webApp("Flotilla", "https://flotilla.social", "Community spaces & chat", "https://framerusercontent.com/images/8UjnVxSvRkmvY2lEYU5z8OMOw0M.png")
webApp("Chachi", "https://chachi.chat", "Group chat & communities")
webApp("NostrChat", "https://www.nostrchat.io", "Decentralized chat", "https://www.nostrchat.io/logo192.png")
webApp("NymChat", "https://www.nymchat.com", "Anonymous, ephemeral chat")
webApp("HiveTalk", "https://hivetalk.org", "Lightning-powered video conferencing", "https://hivetalk.org/_astro/apple-touch-icon.BAevOzwc.png")
// Media — video, photo, audio, podcasts, files
webApp("zap.stream", "https://zap.stream", "Live streaming with Lightning", "https://zap.stream/logo.png")
webApp("Divine Video", "https://divine.video", "6-second looping videos", "https://divine.video/app_icon.png")
webApp("Olas", "https://olas.app", "Photo & media sharing", "https://olas.app/favicon.png")
webApp("Zappix", "https://zappix.app", "Share & discover images", "https://zappix.app/icon-192.png")
webApp("Slidestr", "https://slidestr.net", "Media slideshow viewer", "https://slidestr.net/slidestr.svg")
webApp("Bouquet", "https://bouquet.slidestr.net", "Blossom media manager", "https://bouquet.slidestr.net/bouquet.png")
webApp("YakBak", "https://yakbak.app", "Voice messages", "https://yakbak.app/yakbak-logo.png")
webApp("ZapTrax", "https://zaptrax.app", "Music streaming with Wavlake", "https://zaptrax.app/icon-192.png")
webApp("Podstr", "https://podstr.org", "Podcasts on Nostr", "https://podstr.org/favicon.svg")
webApp("Nests", "https://nostrnests.com", "Live audio rooms", "https://nostrnests.com/apple-touch-icon.png")
// Knowledge / wiki
webApp("Wikifreedia", "https://wikifreedia.xyz", "Decentralized encyclopedia", "https://wikifreedia.xyz/favicon.svg")
webApp("Wikistr", "https://wikistr.com", "A wiki built on Nostr", "https://wikistr.com/favicon.png")
// Marketplace / food
webApp("Shopstr", "https://shopstr.store", "Bitcoin-native marketplace")
webApp("Plebeian Market", "https://plebeian.market", "Decentralized marketplace", "https://plebeian.market/logo-st5zpap9.svg")
webApp("Zap Cooking", "https://zap.cooking", "Recipes & food culture", "https://zap.cooking/favicon.svg")
// Tools / utilities
webApp("Emojito", "https://emojito.meme", "Custom emoji sets")
webApp("Formstr", "https://formstr.app", "Decentralized forms", "https://formstr.app/logo192.png")
webApp("Nostree", "https://nostree.me", "Link-in-bio pages")
webApp("Badges", "https://badges.page", "Create & award badges")
webApp("Nstart", "https://nstart.me", "Guided account onboarding", "https://nstart.me/favicon.png")
webApp("alphaama", "https://alphaama.com", "Nostr tools & experiments")
webApp("Treasures", "https://treasures.to", "Geocaching on Nostr", "https://treasures.to/apple-touch-icon.png")
webApp("Yondar", "https://yondar.me", "Places & maps", "https://yondar.me/apple-touch-icon.png")
webApp("DTAN", "https://dtan.xyz", "Torrents on Nostr")
webApp("Nostrocket", "https://nostrocket.org", "Project coordination")
webApp("Plektos", "https://plektos.app", "Decentralized meetup events", "https://plektos.app/icon-180.png")
webApp("Zaplytics", "https://zaplytics.app", "Zap analytics for creators")
webApp("Brainstorm", "https://brainstorm.world", "Web-of-trust explorer", "https://brainstorm.world/brainstorm.svg")
webApp("MAKIMONO", "https://makimono.lumilumi.app", "Long-form article editor", "https://makimono.lumilumi.app/favicon3.png")
webApp("Primal Studio", "https://studio.primal.net", "Schedule & publish content")
webApp("nostr.build", "https://nostr.build", "Media & image uploads", "https://nostr.build/apple-touch-icon.png")
webApp("nostrcheck", "https://nostrcheck.me", "Media hosting & NIP-05", "https://nostrcheck.me/apple-touch-icon.png")
webApp("Metadata", "https://metadata.nostr.com", "Edit your profile metadata")
// Games
webApp("Plebs vs Zombies", "https://www.plebsvszombies.cc", "Clean up your follow list", "https://www.plebsvszombies.cc/favicon.svg")
webApp("Blobbi", "https://www.blobbi.pet", "A virtual pet game", "https://www.blobbi.pet/icons/apple-touch-icon.png")
}
// addedAt is 0L: these are hardcoded suggestions, not user-added favorites, so they never need a
// real "added" timestamp for ordering — the curated order in `list` is what matters. `icon` is the
// app's own logo URL, or null to fall back to the globe glyph until a favicon is captured on visit.
private fun MutableList<SuggestedWebApp>.webApp(
label: String,
url: String,
description: String,
icon: String? = null,
) {
add(SuggestedWebApp(FavoriteApp.WebApp(url = url, label = label, addedAt = 0L, iconUrl = icon), description))
}
}
@@ -24,6 +24,7 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.res.ColorStateList
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
@@ -40,6 +41,7 @@ import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
@@ -58,7 +60,6 @@ import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
@@ -94,6 +95,10 @@ class NappletBrowserActivity : ComponentActivity() {
private var controlSheet: NappletControlSheet? = null
private var consolePanel: NappletConsolePanel? = null
// A thin determinate progress bar pinned to the top edge (browser-style), driven by the chrome
// client's onProgressChanged; hidden at 100%.
private val topProgressBar by lazy { buildTopProgressBar() }
// Visit-history gating: only a clean main-frame load (no error) is recorded, so a misspelled/
// unresolved address never enters history. Reset on each main-frame page start.
private var pendingMainFrameUrl: String? = null
@@ -200,6 +205,8 @@ class NappletBrowserActivity : ComponentActivity() {
Gravity.BOTTOM,
),
)
// Added last so the thin loading bar paints above the content (and over the grabber's top edge).
addView(topProgressBar)
}
setContentView(root)
// Pad by the system bars + cutout, but NOT the IME — windowSoftInputMode=adjustResize shrinks the
@@ -307,16 +314,20 @@ class NappletBrowserActivity : ComponentActivity() {
safeBrowsingEnabled = true
}
}
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(wv.settings, true)
}
WebView.setWebContentsDebuggingEnabled(false)
wv.webViewClient = BrowserClient()
wv.webChromeClient = BrowserChromeClient()
}
/** Captures favicon and console output; the only source of both is the WebChromeClient. */
/** Captures favicon and console output, and drives the top loading bar; all come from the WebChromeClient. */
private inner class BrowserChromeClient : WebChromeClient() {
override fun onProgressChanged(
view: WebView,
newProgress: Int,
) {
updateLoadProgress(newProgress)
}
override fun onReceivedIcon(
view: WebView,
icon: Bitmap?,
@@ -377,6 +388,15 @@ class NappletBrowserActivity : ComponentActivity() {
// A main-frame failure (DNS miss on a misspelled host, no connection, …) disqualifies this
// navigation from history. Sub-resource errors are irrelevant to whether the page opened.
if (request.isForMainFrame) mainFrameLoadFailed = true
logConsoleError(request, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty()))
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
logConsoleError(request, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty()))
}
override fun onPageCommitVisible(
@@ -658,6 +678,39 @@ class NappletBrowserActivity : ComponentActivity() {
addView(ProgressBar(this@NappletBrowserActivity))
}
/**
* A thin determinate progress bar pinned to the top edge, like a browser's. Driven by
* [BrowserChromeClient.onProgressChanged]: visible while the page loads and gone at 100%.
*/
private fun buildTopProgressBar(): ProgressBar =
ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
max = 100
isIndeterminate = false
visibility = View.GONE
progressTintList = ColorStateList.valueOf(resolveThemeColor(android.R.attr.colorPrimary))
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, dp(3), Gravity.TOP)
}
/** Shows the thin top bar at [progress]% while loading, hiding it once the page is fully loaded. */
private fun updateLoadProgress(progress: Int) {
if (progress >= 100) {
topProgressBar.visibility = View.GONE
} else {
topProgressBar.progress = progress
topProgressBar.visibility = View.VISIBLE
}
}
/** Appends a single ERROR line to the console panel and refreshes the chrome's unread count. */
private fun logConsoleError(
request: WebResourceRequest,
message: String,
) {
val panel = consolePanel ?: return
panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0)
controlSheet?.updateConsoleCount(panel.entryCount)
}
private fun resolveThemeColor(attr: Int): Int {
val tv = android.util.TypedValue()
theme.resolveAttribute(attr, tv, true)
@@ -48,7 +48,6 @@ import androidx.annotation.RequiresApi
import androidx.privacysandbox.ui.provider.toCoreLibInfo
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
@@ -313,9 +312,6 @@ class NappletBrowserService : Service() {
safeBrowsingEnabled = true
}
}
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(wv.settings, true)
}
WebView.setWebContentsDebuggingEnabled(false)
wv.webViewClient = BrowserClient(tab)
wv.webChromeClient = BrowserChromeClient(tab)
@@ -24,6 +24,7 @@ import android.app.AlertDialog
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.content.res.ColorStateList
import android.net.Uri
import android.os.Bundle
import android.os.Handler
@@ -37,6 +38,9 @@ import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
@@ -58,7 +62,6 @@ import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
@@ -147,6 +150,18 @@ class NappletHostActivity : ComponentActivity() {
private val contentFrame by lazy { FrameLayout(this) }
private val uiScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// The loading splash (monogram + spinner). Kept on top of the mounted WebView and removed only on
// first paint, so there's never a blank/dark gap between the index probe and the shell's first frame.
private var loadingView: View? = null
// A thin determinate progress bar pinned to the top edge (browser-style), driven by the
// WebChromeClient's onProgressChanged; hidden at 100%.
private val topProgressBar by lazy { buildTopProgressBar() }
// Bottom pull-up developer console: the page's console.log/warn/error plus any resource load errors.
private var consolePanel: NappletConsolePanel? = null
private var controlSheet: NappletControlSheet? = null
// Set once the WebView has begun loading the shell, so a retry doesn't reload it.
private var started = false
@@ -268,6 +283,18 @@ class NappletHostActivity : ComponentActivity() {
Gravity.TOP,
),
)
addView(
buildConsolePanel(),
FrameLayout
.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM,
),
)
// Added last so the thin loading bar paints above the content (and over the grabber's top
// edge); it's GONE except while loading, so it never obscures the trusted chrome.
addView(topProgressBar)
}
setContentView(root)
// Activities are edge-to-edge by default on recent Android; pad by the system bar and
@@ -295,22 +322,26 @@ class NappletHostActivity : ComponentActivity() {
*/
private fun probeAndMount() {
contentFrame.removeAllViews()
contentFrame.addView(buildLoadingView())
loadingView = buildLoadingView().also { contentFrame.addView(it) }
uiScope.launch {
val available = withContext(Dispatchers.IO) { contentServer.resolve("/") is StaticSiteResolution.Resolved }
if (available) {
mountWebView()
} else {
contentFrame.removeAllViews()
loadingView = null
contentFrame.addView(buildErrorView { probeAndMount() })
}
}
}
private fun mountWebView() {
contentFrame.removeAllViews()
(webView.parent as? ViewGroup)?.removeView(webView)
contentFrame.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
// Mount the WebView UNDER the loading splash (index 0) instead of replacing it: the shell + applet
// bundle still take time to paint (seconds over Tor), and the WebView shows only its dark
// colorBackground until then. The splash stays until the first frame paints (onPageCommitVisible),
// so the user never sees a blank/black screen with no sign that anything is loading.
contentFrame.addView(webView, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
if (!started) {
started = true
webView.loadUrl(NappletWebContract.SHELL_URL)
@@ -475,15 +506,13 @@ class NappletHostActivity : ComponentActivity() {
safeBrowsingEnabled = true
}
}
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(webView.settings, true)
}
// Disable the overscroll stretch/glow: forcing a scroll past the content edge stretched the
// WebView's output and exposed the shell document's background behind the applet iframe at the
// seam (a stray white band at the bottom). The applet's own content still scrolls normally.
webView.overScrollMode = View.OVER_SCROLL_NEVER
WebView.setWebContentsDebuggingEnabled(false)
webView.webViewClient = NappletWebViewClient()
webView.webChromeClient = NappletWebChromeClient()
}
/**
@@ -529,6 +558,34 @@ class NappletHostActivity : ComponentActivity() {
syncBackState()
}
// The shell has painted its first frame — drop the loading splash so the running app shows
// through. Null-safe so a later in-app navigation/reload (splash already gone) is a no-op.
override fun onPageCommitVisible(
view: WebView,
url: String,
) {
loadingView?.let { contentFrame.removeView(it) }
loadingView = null
}
// Surface failed resource fetches (a missing blob, a verify miss, an off-origin request the
// default-deny CSP blocked) in the console so an nsite/napplet developer can see what broke.
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
logConsoleError(request, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty()))
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
logConsoleError(request, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty()))
}
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
@@ -550,6 +607,43 @@ class NappletHostActivity : ComponentActivity() {
}
}
/** Drives the top loading bar and forwards the applet/site's `console.*` output to the console panel. */
private inner class NappletWebChromeClient : WebChromeClient() {
override fun onProgressChanged(
view: WebView,
newProgress: Int,
) {
updateLoadProgress(newProgress)
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
val panel = consolePanel ?: return false
panel.appendLog(consoleMessage.messageLevel(), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber())
controlSheet?.updateConsoleCount(panel.entryCount)
return true
}
}
/** Shows the thin top bar at [progress]% while loading, hiding it once the page is fully loaded. */
private fun updateLoadProgress(progress: Int) {
if (progress >= 100) {
topProgressBar.visibility = View.GONE
} else {
topProgressBar.progress = progress
topProgressBar.visibility = View.VISIBLE
}
}
/** Appends a single ERROR line to the console panel and refreshes the chrome's unread count. */
private fun logConsoleError(
request: WebResourceRequest,
message: String,
) {
val panel = consolePanel ?: return
panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0)
controlSheet?.updateConsoleCount(panel.entryCount)
}
// ---- bridge: shell <-> native ----
private fun onShellMessage(
@@ -663,6 +757,9 @@ class NappletHostActivity : ComponentActivity() {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setPadding(dp(32), dp(32), dp(32), dp(32))
// Opaque so the splash/error screen fully covers the WebView it now overlays (mounted beneath
// it until first paint) instead of letting the dark, not-yet-painted page show through.
setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
}
@@ -736,30 +833,33 @@ class NappletHostActivity : ComponentActivity() {
title = barTitle(),
isSandbox = true,
onReload = { if (this::webView.isInitialized) webView.reload() },
// Website-mode nSites can re-route over Tor; switching rebuilds the session via a confirm
// dialog, so the row taps through rather than toggling inline.
// Website-mode nSites can re-route over Tor; switching rebuilds the session, so the row taps
// through to a full relaunch rather than toggling inline.
torInitiallyOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null,
onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ showNetworkDialog() }) else null,
onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ setNetworkMode(!useTor) }) else null,
onInfo = { showAccessDialog() },
)
onConsole = { consolePanel?.toggle() },
).also { controlSheet = it }
private fun buildConsolePanel(): View =
NappletConsolePanel(this).also {
it.onClearCallback = { controlSheet?.updateConsoleCount(0) }
consolePanel = it
}
/**
* Explains the site's current network routing and offers to switch it. Switching persists the
* per-site choice (via the broker, which owns the preference) and relaunches this screen so the new
* routing applies cleanly from [onCreate] the proxy and content server are rebuilt for the new mode.
* A thin determinate progress bar pinned to the top edge, like a browser's. Driven by
* [NappletWebChromeClient.onProgressChanged]: visible while the shell + verified blobs load and gone
* at 100%, so a slow load (e.g. a large bundle over Tor) shows progress instead of a blank dark WebView.
*/
private fun showNetworkDialog() {
val titleRes = if (useTor) R.string.napplet_net_tor_title else R.string.napplet_net_open_title
val messageRes = if (useTor) R.string.napplet_net_tor_message else R.string.napplet_net_open_message
val switchRes = if (useTor) R.string.napplet_net_switch_open else R.string.napplet_net_switch_tor
AlertDialog
.Builder(this)
.setTitle(getString(titleRes, barTitle()))
.setMessage(getString(messageRes))
.setPositiveButton(getString(switchRes)) { _, _ -> setNetworkMode(!useTor) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun buildTopProgressBar(): ProgressBar =
ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
max = 100
isIndeterminate = false
visibility = View.GONE
progressTintList = ColorStateList.valueOf(resolveThemeColor(android.R.attr.colorPrimary))
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, dp(3), Gravity.TOP)
}
/** Persists the new routing choice in the main process, then relaunches this screen to apply it. */
private fun setNetworkMode(newUseTor: Boolean) {
@@ -50,7 +50,6 @@ import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
@@ -340,9 +339,6 @@ class NappletHostService : Service() {
safeBrowsingEnabled = true
}
}
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(wv.settings, true)
}
wv.overScrollMode = View.OVER_SCROLL_NEVER
WebView.setWebContentsDebuggingEnabled(false)
wv.webViewClient = HostClient(tab)
+4 -6
View File
@@ -19,12 +19,6 @@
<!-- nSite network routing (Tor vs open web) -->
<string name="napplet_net_tor_desc">This site loads over Tor. Tap to change.</string>
<string name="napplet_net_open_desc">This site loads over the open web. Tap to change.</string>
<string name="napplet_net_tor_title">“%1$s” loads over Tor</string>
<string name="napplet_net_tor_message">This site\'s traffic is routed through Tor, so it can\'t see your IP address. Some sites are slow or broken over Tor — you can switch this site to the open web. Your choice is remembered for this site.</string>
<string name="napplet_net_open_title">“%1$s” loads over the open web</string>
<string name="napplet_net_open_message">This site loads directly, so it (and the servers it contacts) can see your IP address. Switch it back to Tor to keep your IP private. Your choice is remembered for this site.</string>
<string name="napplet_net_switch_open">Use open web</string>
<string name="napplet_net_switch_tor">Use Tor</string>
<!-- Short labels for the pull-down sheet's network row -->
<string name="napplet_net_tor_label">Loads over Tor</string>
<string name="napplet_net_open_label">Loads over the open web</string>
@@ -37,4 +31,8 @@
<string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string>
<string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string>
<string name="napplet_unavailable_retry">Try again</string>
<!-- Developer console: page-load failures surfaced as console errors -->
<string name="napplet_console_load_error">Failed to load (%1$d): %2$s</string>
<string name="napplet_console_http_error">HTTP %1$d %2$s</string>
</resources>