chore: merge main — keep both theme and isFavorite params in NappletBrowserActivity

Resolved conflicts from main adding isFavorite to NappletBrowserActivity.intent()
and FavoriteAppLauncher.launchUrl() while our branch added the theme parameter.
Both params are now present together.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
This commit is contained in:
Claude
2026-06-25 13:28:25 +00:00
44 changed files with 1315 additions and 91 deletions
@@ -89,8 +89,9 @@ object FavoriteAppLauncher {
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
}
}
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
val intent =
NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme).apply {
NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme, isFavorite = isFavorite).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
@@ -32,6 +32,7 @@ import android.os.RemoteException
import android.os.SystemClock
import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
@@ -41,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
@@ -175,6 +177,21 @@ class NappletBrokerService : Service() {
return true
}
// The direct-WebView browser requests a favorite toggle for the current URL (main process only).
if (msg.what == NappletIpc.MSG_TOGGLE_WEB_FAVORITE) {
val data = msg.data ?: return true
val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true
val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url }
FavoriteAppsRegistry.init(applicationContext)
val id = "url:$url"
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebUrl(url, label, System.currentTimeMillis()))
}
return true
}
// The direct-WebView browser relays its per-host Tor choice; persist it (main process only).
if (msg.what == NappletIpc.MSG_SET_WEB_TOR) {
val data = msg.data ?: return true
@@ -342,7 +342,7 @@ class NotificationRelayService : Service() {
.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.always_on_notif_title))
.setContentText(contentText)
.setSmallIcon(R.drawable.amethyst)
.setSmallIcon(R.drawable.amethyst_service)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSilent(true)
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource.UrlFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.WorkoutsFilterAssembler
@@ -116,6 +117,7 @@ class RelaySubscriptionsCoordinator(
val profile = UserProfileFilterAssembler(client)
val hashtags = HashtagFilterAssembler(client)
val geohashes = GeoHashFilterAssembler(client)
val urls = UrlFilterAssembler(client)
val relayFeed = RelayFeedFilterAssembler(client)
val relayInfoNip66 = RelayInfoNip66FilterAssembler(client)
val followPacks = FollowPackFeedFilterAssembler(client)
@@ -209,6 +211,7 @@ class RelaySubscriptionsCoordinator(
profile,
hashtags,
geohashes,
urls,
relayFeed,
relayInfoNip66,
chess,
@@ -51,8 +51,10 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.UriParser
import java.net.URLDecoder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -147,6 +149,18 @@ fun isNotificationRoute(uri: String) = uri.startsWith("notifications", true) ||
fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWith("nostr:hashtag?id=")
fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=")
fun urlRoute(uri: String): Route.Url? {
val url =
runCatching {
val rawUrl = java.net.URI(uri.removePrefix("nostr:")).findParameterValue("id") ?: return null
URLDecoder.decode(rawUrl, Charsets.UTF_8.name())
}.getOrNull() ?: return null
return UrlId.toScopeOrNull(url)?.let { Route.Url(it) }
}
fun isWalletConnectRoute(uri: String) = uri.startsWith("dlnwc?value=") || uri.startsWith("amethyst+walletconnect:dlnwc?value=") || uri.startsWith("amethyst+walletconnect://dlnwc?value=")
fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:")
@@ -164,6 +178,9 @@ fun uriToRoute(
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase())
}
if (isUrlRoute(uri)) {
return urlRoute(uri)
}
val nip19 = Nip19Parser.uriToRoute(uri)?.entity
if (nip19 != null) {
@@ -24,9 +24,7 @@ import android.graphics.drawable.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -77,9 +75,13 @@ fun GifVideoView(
val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier
val context = LocalContext.current
// Share the static-image sizing policy so animated media obeys it too: in Crop
// contexts (e.g. the multi-image gallery grid) the cell has a fixed width AND height,
// so the content must fill and crop rather than impose its own aspect ratio, which
// would overflow the cell and spill over neighbouring content like the reaction row.
val containerModifier =
(if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier)
.fillMaxWidth()
borderModifier
.then(mediaSizingModifier(ratio, contentScale))
.let { if (onDialog != null) it.clickable { onDialog() } else it }
Box(
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
@@ -39,11 +41,12 @@ fun LoadUrlPreview(
urlText: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav? = null,
) {
if (!accountViewModel.settings.showUrlPreview()) {
ClickableUrl(urlText, url)
} else {
LoadUrlPreviewDirect(url, urlText, callbackUri, accountViewModel)
LoadUrlPreviewDirect(url, urlText, callbackUri, accountViewModel, nav)
}
}
@@ -53,6 +56,7 @@ fun LoadUrlPreviewDirect(
urlText: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav? = null,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val urlPreviewState by
@@ -72,7 +76,7 @@ fun LoadUrlPreviewDirect(
) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
RenderLoaded(state, url, callbackUri, accountViewModel)
RenderLoaded(state, url, callbackUri, accountViewModel, nav)
}
is UrlPreviewState.Loading -> {
@@ -94,6 +98,7 @@ fun RenderLoaded(
url: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav? = null,
) {
when {
state.previewInfo.mimeType.startsWith("image") -> {
@@ -130,7 +135,14 @@ fun RenderLoaded(
}
else -> {
UrlPreviewCard(url, state.previewInfo)
UrlPreviewCard(
url,
state.previewInfo,
onUrlComments =
nav?.let {
{ it.nav(Route.Url(url)) }
},
)
}
}
}
@@ -549,7 +549,7 @@ private fun RenderWordWithPreview(
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel, nav)
is NowhereLinkSegment -> NowhereLinkCard(word)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
@@ -52,6 +52,7 @@ import kotlinx.coroutines.launch
fun UrlPreviewCard(
url: String,
previewInfo: UrlInfoItem,
onUrlComments: (() -> Unit)? = null,
) {
val uri = LocalUriHandler.current
val popupExpanded =
@@ -76,6 +77,15 @@ fun UrlPreviewCard(
popupExpanded.value = false
}
}
onUrlComments?.let {
M3ActionRow(
icon = MaterialSymbols.Link,
text = stringRes(R.string.kind_comments),
) {
popupExpanded.value = false
it()
}
}
}
}
}
@@ -132,7 +132,7 @@ class MarkdownMediaRenderer(
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
} else {
renderInlineFullWidth(richTextStringBuilder) {
LoadUrlPreview(uri, title ?: uri, callbackUri, accountViewModel)
LoadUrlPreview(uri, title ?: uri, callbackUri, accountViewModel, nav)
}
}
}
@@ -214,6 +214,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.UrlPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.UrlScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddCashuWalletScreen
@@ -460,6 +462,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Url> { UrlScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.RelayFeed> { RelayFeedScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
@@ -570,6 +573,19 @@ fun BuildNavigation(
)
}
composableFromBottomArgs<Route.UrlPost> {
UrlPostScreen(
url = it.url,
message = it.message,
attachment = it.attachment,
replyId = it.replyTo,
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
nav,
)
}
composableFromBottomArgs<Route.GenericCommentPost> {
ReplyCommentPostScreen(
replyId = it.replyTo,
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.utils.Log
import java.net.URLEncoder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -144,6 +145,10 @@ class BouncingIntentNav(
NOSTR_URI_PREFIX + "hashtag?id=" + route.hashtag
}
is Route.Url -> {
NOSTR_URI_PREFIX + "url?id=" + URLEncoder.encode(route.url, Charsets.UTF_8.name())
}
is Route.LiveActivityChannel -> {
NOSTR_URI_PREFIX + NAddress.create(route.kind, route.pubKeyHex, route.dTag, null)
}
@@ -477,6 +477,10 @@ sealed class Route {
val geohash: String,
) : Route()
@Serializable data class Url(
val url: String,
) : Route()
@Serializable data class ChessGame(
val gameId: String,
) : Route()
@@ -691,6 +695,16 @@ sealed class Route {
val draft: String? = null,
) : Route()
@Serializable
data class UrlPost(
val url: String? = null,
val message: String? = null,
val attachment: String? = null,
val replyTo: String? = null,
val quote: String? = null,
val draft: String? = null,
) : Route()
@Serializable
data class GenericCommentPost(
val message: String? = null,
@@ -84,7 +84,7 @@ fun PreviewUrl(
}
else -> {
MyLoadUrlPreviewDirect(myUrlPreview, myUrlPreview, accountViewModel)
MyLoadUrlPreviewDirect(myUrlPreview, myUrlPreview, accountViewModel, nav)
}
}
}
@@ -104,7 +104,7 @@ fun PreviewUrl(
}
RichTextParser.isUrlWithoutScheme(myUrlPreview) -> {
MyLoadUrlPreviewDirect("https://$myUrlPreview", myUrlPreview, accountViewModel)
MyLoadUrlPreviewDirect("https://$myUrlPreview", myUrlPreview, accountViewModel, nav)
}
}
}
@@ -138,7 +138,7 @@ fun PreviewUrlFillWidth(
}
else -> {
MyLoadUrlPreviewDirectFillWidth(myUrlPreview, myUrlPreview, accountViewModel)
MyLoadUrlPreviewDirectFillWidth(myUrlPreview, myUrlPreview, accountViewModel, nav)
}
}
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
@@ -154,7 +154,7 @@ fun PreviewUrlFillWidth(
nav = nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
MyLoadUrlPreviewDirectFillWidth("https://$myUrlPreview", myUrlPreview, accountViewModel)
MyLoadUrlPreviewDirectFillWidth("https://$myUrlPreview", myUrlPreview, accountViewModel, nav)
}
}
@@ -200,6 +200,7 @@ private fun MyLoadUrlPreviewDirect(
url: String,
urlText: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val urlPreviewState by
@@ -268,6 +269,7 @@ private fun MyLoadUrlPreviewDirectFillWidth(
url: String,
urlText: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val urlPreviewState by
@@ -304,7 +306,11 @@ private fun MyLoadUrlPreviewDirectFillWidth(
accountViewModel = accountViewModel,
)
} else {
UrlPreviewCard(url, previewInfo = state.previewInfo)
UrlPreviewCard(
url,
previewInfo = state.previewInfo,
onUrlComments = { nav.nav(com.vitorpamplona.amethyst.ui.navigation.routes.Route.Url(url)) },
)
}
}
@@ -30,7 +30,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.buildAnnotatedString
@@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -68,7 +68,7 @@ fun DisplayExternalId(
}
is UrlId -> {
DisplayUrlExternalId(externalId)
DisplayUrlExternalId(externalId, nav)
}
else -> {
@@ -78,13 +78,15 @@ fun DisplayExternalId(
}
@Composable
fun DisplayUrlExternalId(externalId: UrlId) {
val uriHandler = LocalUriHandler.current
fun DisplayUrlExternalId(
externalId: UrlId,
nav: INav,
) {
DisplayExternalIdChip(
symbol = MaterialSymbols.Link,
contentDescription = stringRes(id = R.string.external_url_scope),
label = externalId.url,
linkInteractionListener = { runCatching { uriHandler.openUri(externalId.url) } },
linkInteractionListener = { nav.nav(Route.Url(externalId.url)) },
)
}
@@ -195,12 +195,6 @@ private fun BrowserLauncher(
onValueChange = ::onValueChange,
onClear = { field = TextFieldValue("") },
onOpen = { open(field.text) },
onFavorite = {
val url = OmniboxInput.resolve(field.text)?.url ?: return@OmniBar
FavoriteAppsRegistry.add(
FavoriteApp.WebUrl(url = url, label = hostOf(url), addedAt = System.currentTimeMillis()),
)
},
)
},
bottomBar = {
@@ -264,7 +258,6 @@ private fun OmniBar(
onValueChange: (TextFieldValue) -> Unit,
onClear: () -> Unit,
onOpen: () -> Unit,
onFavorite: () -> Unit,
) {
Row(
modifier =
@@ -309,9 +302,6 @@ private fun OmniBar(
),
)
if (field.text.isNotBlank()) {
IconButton(onClick = onFavorite) {
Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add))
}
IconButton(onClick = onOpen) {
Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringResource(R.string.browser_go))
}
@@ -44,9 +44,12 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
@@ -104,6 +107,9 @@ private fun EmbeddedFavoriteTab(
// can opt one out and it must stick). Only meaningful when Tor is actually available.
var torOn by remember { mutableStateOf(proxyAvailable && WebUrlNetworkRegistry.useTor(url)) }
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebUrl && it.url == currentUrl } }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
val controller =
@@ -121,7 +127,7 @@ private fun EmbeddedFavoriteTab(
// Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame.
val chrome =
remember(currentUrl, torOn, proxyAvailable) {
remember(currentUrl, torOn, proxyAvailable, isFavorite) {
EmbeddedTabChrome(
title = hostLabel(currentUrl),
isSandbox = false,
@@ -133,6 +139,15 @@ private fun EmbeddedFavoriteTab(
controller.setTor(torOn)
WebUrlNetworkRegistry.set(url, torOn)
},
isFavorite = isFavorite,
onFavorite = {
val favId = "url:$currentUrl"
if (FavoriteAppsRegistry.isFavorite(favId)) {
FavoriteAppsRegistry.remove(favId)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebUrl(currentUrl, hostLabel(currentUrl), System.currentTimeMillis()))
}
},
)
}
// Publish the top-sheet controls to the tab layer (which draws them over the z-below surface). In a
@@ -128,43 +128,8 @@ fun BottomConsoleSheet(
.verticalScroll(scrollState)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
val dimColor = MaterialTheme.colorScheme.onSurfaceVariant
logs.forEach { entry ->
val levelColor = consoleLevelColor(entry.level)
val srcShort =
entry.source
.substringAfterLast("/")
.substringAfterLast("\\")
.let { if (it.isBlank()) entry.source.takeLast(20) else it }
Row(
Modifier.fillMaxWidth().padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Text(
consoleLevelChar(entry.level),
color = levelColor,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
modifier = Modifier.width(14.dp),
)
Spacer(Modifier.width(4.dp))
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = levelColor)) {
append(entry.message)
}
if (srcShort.isNotBlank()) {
withStyle(SpanStyle(color = dimColor)) {
append(" $srcShort:${entry.lineNumber}")
}
}
},
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
overflow = TextOverflow.Visible,
)
}
ConsoleLogRow(entry)
}
}
}
@@ -203,6 +168,46 @@ fun BottomConsoleSheet(
}
}
@Composable
private fun ConsoleLogRow(entry: ConsoleLogEntry) {
val levelColor = consoleLevelColor(entry.level)
val dimColor = MaterialTheme.colorScheme.onSurfaceVariant
val srcShort =
entry.source
.substringAfterLast("/")
.substringAfterLast("\\")
.let { if (it.isBlank()) entry.source.takeLast(20) else it }
Row(
Modifier.fillMaxWidth().padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Text(
consoleLevelChar(entry.level),
color = levelColor,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
modifier = Modifier.width(14.dp),
)
Spacer(Modifier.width(4.dp))
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = levelColor)) {
append(entry.message)
}
if (srcShort.isNotBlank()) {
withStyle(SpanStyle(color = dimColor)) {
append(" $srcShort:${entry.lineNumber}")
}
}
},
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
overflow = TextOverflow.Visible,
)
}
}
@Composable
private fun consoleLevelColor(level: String): Color {
val warningAmber = if (isSystemInDarkTheme()) Color(0xFFFFB74D) else Color(0xFFE65100)
@@ -37,4 +37,8 @@ data class EmbeddedTabChrome(
val onToggleTor: () -> Unit = {},
/** The "what it can access" sheet, for sandboxed napplets/nsites; null for a plain web client. */
val onInfo: (() -> Unit)? = null,
/** Whether the current URL/app is already saved as a favorite. */
val isFavorite: Boolean = false,
/** Toggles the current site/app in the favorites registry; null when not applicable. */
val onFavorite: (() -> Unit)? = null,
)
@@ -131,6 +131,15 @@ fun TopControlSheet(
onExpandedChange(false)
chrome.onOpenFull()
}
chrome.onFavorite?.let { toggleFavorite ->
SheetItem(
if (chrome.isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder,
stringResource(if (chrome.isFavorite) R.string.favorite_app_remove else R.string.favorite_app_add),
) {
onExpandedChange(false)
toggleFavorite()
}
}
onConsole?.let { showConsole ->
SheetItem(
MaterialSymbols.Code,
@@ -53,9 +53,11 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
@@ -125,6 +127,9 @@ private fun EmbeddedNappletTab(
var canGoBack by remember { mutableStateOf(false) }
var showAccess by remember { mutableStateOf(false) }
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } }
val controller =
remember(id) {
EmbeddedTabFactory.acquireNapplet(context, coordinate, params, backgroundColor)
@@ -138,15 +143,24 @@ private fun EmbeddedNappletTab(
}
}
// Stable per app (title/coordinate don't change), so the tab layer isn't recomposed every frame.
// Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame.
val chrome =
remember(title, coordinate) {
remember(title, coordinate, isFavorite) {
EmbeddedTabChrome(
title = title.ifBlank { coordinate },
isSandbox = true,
onReload = { controller.reload() },
onOpenFull = { FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) },
onInfo = { showAccess = true },
isFavorite = isFavorite,
onFavorite = {
val favId = "nostr:$coordinate"
if (FavoriteAppsRegistry.isFavorite(favId)) {
FavoriteAppsRegistry.remove(favId)
} else {
FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()))
}
},
)
}
// Publish the top-sheet controls to the tab layer (drawn over the z-below surface). In a SideEffect
@@ -0,0 +1,59 @@
/*
* 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.url
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@Composable
fun NewUrlPostButton(
url: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
FloatingActionButton(
onClick = {
nav.nav(Route.UrlPost(url))
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
painter = painterRes(R.drawable.ic_compose, 1),
contentDescription = stringRes(id = R.string.new_community_note),
modifier = Size26Modifier,
tint = Color.White,
)
}
}
@@ -0,0 +1,84 @@
/*
* 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.url
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel
import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun UrlPostScreen(
url: String? = null,
message: String? = null,
attachment: String? = null,
replyId: HexKey? = null,
quoteId: HexKey? = null,
draftId: HexKey? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
val postViewModel: CommentPostViewModel = viewModel()
postViewModel.init(accountViewModel)
val context = LocalContext.current
LaunchedEffect(postViewModel, accountViewModel) {
url?.let {
UrlId.toScopeOrNull(it)?.let { normalizedUrl ->
postViewModel.newPostFor(UrlId(normalizedUrl))
}
}
replyId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.reply(it)
}
draftId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.editFromDraft(it)
}
quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let {
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {
val mediaType = context.contentResolver.getType(it)
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
}
}
}
GenericCommentPostScreen(postViewModel, accountViewModel, nav)
}
@@ -0,0 +1,122 @@
/*
* 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.url
import android.annotation.SuppressLint
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal.UrlFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource.UrlFilterAssemblerSubscription
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
@Composable
fun UrlScreen(
route: Route.Url,
accountViewModel: AccountViewModel,
nav: INav,
) {
val url = UrlId.toScopeOrNull(route.url) ?: return
PrepareViewModelsUrlScreen(url, accountViewModel, nav)
}
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
fun PrepareViewModelsUrlScreen(
url: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val urlFeedViewModel: UrlFeedViewModel =
viewModel(
key = url + "UrlFeedViewModel",
factory =
UrlFeedViewModel.Factory(
url,
accountViewModel.account.followOutboxesOrProxy.flow.value,
accountViewModel.account,
),
)
UrlScreen(url, urlFeedViewModel, accountViewModel, nav)
}
@Composable
fun UrlScreen(
url: String,
feedViewModel: UrlFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedViewModel)
UrlFilterAssemblerSubscription(url, accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
TopBarExtensibleWithBackButton(
title = {
DisplayUrlHeader(url, Modifier.weight(1f))
},
popBack = nav::popBack,
)
},
floatingButton = {
FabBottomBarPadded(nav) {
NewUrlPostButton(url, accountViewModel, nav)
}
},
accountViewModel = accountViewModel,
) {
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
fun DisplayUrlHeader(
url: String,
modifier: Modifier,
) {
Text(
url,
fontWeight = FontWeight.Bold,
modifier = modifier,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@@ -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.url.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
class UrlFeedFilter(
url: String,
val relays: Set<NormalizedRelayUrl>,
val account: Account,
val cache: LocalCache,
) : AdditiveFeedFilter<Note>() {
val normalizedUrl = UrlId.toScopeOrNull(url).orEmpty()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + normalizedUrl
override fun feed(): List<Note> {
val notes =
cache.notes.filterIntoSet { _, it ->
acceptableEvent(it, normalizedUrl)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { acceptableEvent(it, normalizedUrl) }
fun acceptableEvent(
it: Note,
url: String,
): Boolean =
acceptableViaScope(it.event, url) &&
!it.isHiddenFor(account.hiddenUsers.flow.value) &&
account.isAcceptable(it)
fun acceptableViaScope(
event: com.vitorpamplona.quartz.nip01Core.core.Event?,
url: String,
): Boolean = UrlId.toScopeOrNull(url)?.let { event is CommentEvent && event.isTaggedScope(it, UrlId::match) } ?: false
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
}
@@ -0,0 +1,48 @@
/*
* 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.url.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable
class UrlFeedViewModel(
val url: String,
val relays: Set<NormalizedRelayUrl>,
val account: Account,
) : AndroidFeedViewModel(
UrlFeedFilter(url, relays, account, LocalCache),
) {
@Suppress("UNCHECKED_CAST")
class Factory(
val url: String,
val relays: Set<NormalizedRelayUrl>,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = UrlFeedViewModel(url, relays, account) as T
}
}
@@ -0,0 +1,51 @@
/*
* 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.url.datasource
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
fun filterPostsByUrl(
url: String,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val normalizedUrl = UrlId.toScopeOrNull(url) ?: return emptyList()
val urlScopeMap = mapOf("I" to listOf(normalizedUrl))
return relays.map { relay ->
val since = since?.get(relay)?.time
RelayBasedFilter(
relay = relay,
filter =
Filter(
tags = urlScopeMap,
kinds = CommentKinds,
limit = 100,
since = since,
),
)
}
}
@@ -0,0 +1,38 @@
/*
* 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.url.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class UrlFeedFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<UrlQueryState>,
) : PerUniqueIdEoseManager<UrlQueryState, String>(client, allKeys) {
override fun updateFilter(
key: UrlQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterPostsByUrl(key.normalizedUrl, key.relays, since)
override fun id(key: UrlQueryState) = key.normalizedUrl
}
@@ -0,0 +1,50 @@
/*
* 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.url.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
class UrlQueryState(
val url: String,
val relays: Set<NormalizedRelayUrl>,
) {
val normalizedUrl = UrlId.toScopeOrNull(url).orEmpty()
}
@Stable
class UrlFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<UrlQueryState>() {
val group =
listOf(
UrlFeedFilterSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,41 @@
/*
* 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.url.datasource
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
fun UrlFilterAssemblerSubscription(
url: String,
accountViewModel: AccountViewModel,
) {
val state =
remember(url) {
UrlQueryState(url, accountViewModel.account.followOutboxesOrProxy.flow.value)
}
LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().urls)
}
@@ -1234,17 +1234,25 @@ private fun MintPicker(
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
mints.forEach { m ->
DropdownMenuItem(
text = { Text(m) },
onClick = {
onPick(m)
expanded = false
},
)
MintPickerItem(m) {
onPick(m)
expanded = false
}
}
}
}
@Composable
private fun MintPickerItem(
mint: String,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = { Text(mint) },
onClick = onClick,
)
}
// ============================================================================
// Send LN (melt)
// ============================================================================
@@ -0,0 +1,21 @@
<!--
~ Status-bar icon for the always-on relay-connection service.
~
~ Notification small icons are rendered by the system as flat, alpha-only
~ silhouettes, so the regular `amethyst` gem becomes an identical solid gem
~ for both real notifications and this persistent service. To stop the
~ service from reading as a new notification, this variant draws the gem as
~ a hollow outline — same brand shape, clearly different at a glance.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeWidth="22"
android:strokeLineJoin="round"
android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z" />
</vector>
@@ -276,6 +276,7 @@
<string name="unblock">Odblokuj</string>
<string name="copy_user_id">Kopiuj ID użytkownika</string>
<string name="unblock_user">Odblokuj użytkownika</string>
<string name="user_is_blocked_hidden">Ten użytkownik jest zablokowany/uciszony. Ich posty są ukryte.</string>
<string name="npub_hex_username">"npub, nazwa użytkownika, tekst"</string>
<string name="clear">Wyczyść</string>
<string name="app_logo">Logo aplikacji</string>
@@ -588,6 +589,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="profile_app_recommendations_title">Polecane aplikacje</string>
<string name="profile_app_recommendations_description">Wybierz aplikacje Nostr, które chcesz publicznie polecić. Twoje rekomendacje pojawią się na Twoim profilu i pomogą innym użytkownikom znaleźć aplikacje z treściami, których ten klient nie może otworzyć.</string>
<string name="profile_app_recommendations_empty">Nie znaleziono jeszcze żadnych aplikacji. Aplikacje będą się tu pojawiać w miarę ich wykrywania na Twoich transmiterach.</string>
<string name="profile_app_recommendations_search">Szukaj aplikacji po nazwie</string>
<string name="profile_app_recommendations_search_empty">Brak aplikacji spełniających kryteria wyszukiwania.</string>
<string name="profile_app_recommendations_filter_empty">Żadna rekomendowana aplikacja nie pasuje do tego filtra.</string>
<string name="app_definition_untitled">Aplikacja bez nazwy</string>
<string name="app_definition_no_supported_kinds">Nie podaje, jakie treści obsługuje</string>
<string name="app_definition_recommend">Poleć</string>
@@ -616,6 +620,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="workout_sets">Zestawy</string>
<string name="workout_reps">Powtórzenia</string>
<string name="workout_weight">Waga</string>
<string name="workout_exercises">Ćwiczenia</string>
<string name="workout_volume">Objętość</string>
<string name="workout_notes">Uwagi</string>
<string name="workout_hours">Godziny</string>
<string name="workout_minutes">Minuty</string>
@@ -636,10 +642,103 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="exercise_meditation">Medytacja</string>
<string name="exercise_diet">Dieta</string>
<string name="exercise_fasting">Post</string>
<string name="exercise_circuit">Układ</string>
<string name="exercise_emom">EMOM</string>
<string name="exercise_amrap">AMRAP</string>
<string name="software_apps">Aplikacje</string>
<string name="route_software_apps">Aplikacje</string>
<string name="napplets">nApplets</string>
<string name="nsites">nSites</string>
<string name="nsite_none_found">Nie znaleziono żadnych witryn nSite.</string>
<string name="browser">Przeglądarka</string>
<string name="browser_address_hint">Szukaj lub wprowadź adres</string>
<string name="browser_reload">Odśwież</string>
<string name="browser_console_title_short">Konsola</string>
<string name="browser_console_title">Konsola (%1$d)</string>
<string name="browser_console_clear">Wyczyść</string>
<string name="browser_tor_on">Wczytywanie przez Tor. Kliknij, aby użyć otwartej sieci.</string>
<string name="browser_tor_off">Pobieranie przez otwartą sieć. Kliknij, aby skorzystać z sieci Tor.</string>
<string name="browser_unsupported">Przeglądarka w aplikacji wymaga Androida 11 lub nowszego.</string>
<string name="embedded_tab_load_failed">Nie można załadować tej aplikacji.</string>
<string name="browser_go">Otwórz</string>
<string name="browser_clear">Wyczyść</string>
<string name="browser_favorites">Ulubione</string>
<string name="browser_recent_options">Opcje</string>
<string name="browser_recent_remove">Usuń z historii</string>
<string name="favorite_apps">Ulubione aplikacje</string>
<string name="favorite_apps_empty">Brak ulubionych aplikacji. Otwórz klienta lub witrynę internetową i kliknij gwiazdkę, aby przypiąć ją tutaj.</string>
<string name="favorite_app_add">Dodaj do ulubionych</string>
<string name="favorite_app_remove">Usuń z ulubionych</string>
<string name="favorite_app_open_window">Otwórz w osobnym oknie</string>
<string name="favorite_app_access_title">Do czego ta aplikacja ma dostęp</string>
<string name="favorite_app_access_static">Działa w środowisku izolowanym i nie ma żadnego specjalnego dostępu do Twojego konta.</string>
<string name="favorite_app_access_show">Do czego ma dostęp</string>
<string name="favorite_app_network_tor">Przesyłanie danych przez Tor.</string>
<string name="favorite_app_network_open">Wczytuje się z otwartej sieci.</string>
<string name="favorite_app_unavailable">Ta aplikacja nie została jeszcze załadowana. Otwórz ją z karty lub spróbuj ponownie za chwilę.</string>
<string name="favorite_notice_published">Opublikowane do transmiterów</string>
<string name="favorite_notice_uploaded">Wgrano plik</string>
<string name="favorite_notice_paid">Dokonano płatności</string>
<string name="favorite_app_still_loading">Ta aplikacja nie została jeszcze załadowana. Spróbuj ponownie za chwilę.</string>
<string name="favorite_app_recent">Najnowsze</string>
<string name="napplet_permissions">uprawnienia nApplet</string>
<string name="napplet_manage_permissions">Zarządzaj uprawnieniami</string>
<string name="napplet_permissions_empty">Brak uprawnień nApplet</string>
<string name="napplet_permissions_empty_subtitle">Uprawnienia, które przyznasz dla nApplets pojawią się tutaj.</string>
<string name="napplet_permissions_forget">Zapomnij o tym nApplet</string>
<string name="napplet_permissions_blocked">Zablokowane</string>
<string name="napplet_permissions_revoke">Odwołaj</string>
<string name="napplet_untitled">nApplet bez tytułu</string>
<string name="napplet_none_found">Nie znaleziono jeszcze żadnych nApplets.</string>
<string name="napplet_fallback_title">nApplet %1$s…</string>
<!-- Napplet sandbox chrome (host top bar + live action notices) -->
<!-- Napplet capability names -->
<string name="napplet_cap_shell">Powłoka</string>
<string name="napplet_cap_identity">Tożsamość</string>
<string name="napplet_cap_keys">Operacje klawiatury</string>
<string name="napplet_cap_relay">Transmitery</string>
<string name="napplet_cap_storage">Schowek</string>
<string name="napplet_cap_value">Płatności</string>
<string name="napplet_cap_resource">Sieć</string>
<string name="napplet_cap_upload">Wgrane</string>
<string name="napplet_cap_shell_desc">Zapytaj jakie opcje są dostępne</string>
<string name="napplet_cap_identity_desc">Odczytaj swój klucz publiczny</string>
<string name="napplet_cap_keys_desc">Przypisz skróty klawiszowe</string>
<string name="napplet_cap_relay_desc">Przeczytaj, podpisz i opublikuj swoje wydarzenia</string>
<string name="napplet_cap_storage_desc">Własny prywatny schowek</string>
<string name="napplet_cap_value_desc">Opłać faktury Lightning</string>
<string name="napplet_cap_resource_desc">Pobierz zasoby sieci web i blossom</string>
<string name="napplet_cap_upload_desc">Prześlij pliki na serwer multimediów</string>
<string name="napplet_cap_theme">Motyw</string>
<string name="napplet_cap_notify">Powiadomienia</string>
<string name="napplet_cap_inc">Wiadomości</string>
<string name="napplet_cap_theme_desc">Dopasuj kolory aplikacji</string>
<string name="napplet_cap_notify_desc">Wyświetl powiadomienia</string>
<string name="napplet_cap_inc_desc">Wymieniaj wiadomości z innymi nappletami</string>
<!-- Napplet consent dialog -->
<string name="napplet_consent_capability">Możliwość: %1$s</string>
<string name="napplet_consent_allow_always">Zawsze zezwalaj</string>
<string name="napplet_consent_allow_once">Zezwól jednorazowo</string>
<string name="napplet_consent_deny_always">Nigdy nie zezwalaj</string>
<string name="napplet_consent_not_now">Nie teraz</string>
<string name="napplet_consent_get_pubkey">Ten nApplet chce odczytać Twój klucz publiczny.</string>
<string name="napplet_consent_identity_read">Ten nApplet chce odczytać dane Twojego profilu i konta.</string>
<string name="napplet_consent_publish">Ta aplikacja nApplet chce podpisać i opublikować zdarzenie typu %1$d w Twoim imieniu.</string>
<string name="napplet_consent_publish_preview">Ta aplikacja nApplet chce podpisać i opublikować zdarzenie typu %1$d w Twoim imieniu:</string>
<string name="napplet_consent_publish_encrypted">Ten nApplet chce wysłać zaszyfrowane wydarzenie jako Ty.</string>
<string name="napplet_consent_query">Ten nApplet chce odczytywać wydarzenia z twoich transmiterów.</string>
<string name="napplet_consent_storage">Ta aplikacja nApplet chce korzystać ze swojego prywatnego schowka.</string>
<string name="napplet_consent_pay">Ta aplikacja nApplet chce zapłacić fakturę w systemie Lightning.</string>
<string name="napplet_consent_resource">Ten nApplet chce pobrać zasób internetowy.</string>
<string name="napplet_consent_upload">Ten nApplet chce przesłać plik na Twój serwer multimedialny.</string>
<string name="napplet_consent_notify">Ten nApplet chce pokazywać Ci powiadomienia.</string>
<string name="napplet_consent_sign">Ta witryna chce podpisać wydarzenie %1$d za pomocą Twojego klucza Nostr.</string>
<plurals name="napplet_consent_pay_amount">
<item quantity="one">Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d sat.</item>
<item quantity="few">Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d satoszów.</item>
<item quantity="many">Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d satoszy.</item>
<item quantity="other">Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d satoszów.</item>
</plurals>
<string name="nip82_repository_label">Źródło: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Pobierz</string>
@@ -1203,6 +1302,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="always_on_notif_setting_description">Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości.</string>
<string name="split_notifications_setting_title">Podział powiadomień według obserwowanych</string>
<string name="split_notifications_setting_description">Wyświetl dwie zakładki powiadomień — „Obserwowani” (osoby, które obserwujesz) oraz „Wszyscy”. Wskaźnik nieprzeczytanych powiadomień świeci się tylko w przypadku aktywności osób, które obserwujesz.</string>
<string name="show_messages_in_notifications_setting_title">Pokaż wiadomości</string>
<string name="show_messages_in_notifications_setting_description">W zakładce „Powiadomienia” znajdują się zarówno wiadomości bezpośrednie, jak i grupowe. Wyłącz tę opcję, aby wiadomości były wyświetlane wyłącznie w zakładce „Wiadomości”.</string>
<string name="notification_tab_following">Obserwowani</string>
<string name="notification_tab_everyone">Wszyscy</string>
<string name="battery_optimization_title">Optymalizacja baterii aktywna</string>
@@ -2223,9 +2324,13 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="git_repo_section_topics">Tematy</string>
<string name="git_repo_personal_fork">Osobisty fork</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>
<string name="profile_tab_apps">Aplikacje &amp; Witryny</string>
<string name="nsite_root_site">Strona główna</string>
<string name="nsite_source">Źródło:</string>
<string name="nsite_servers">Serwery:</string>
<string name="nsite_open">Otwórz</string>
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Potwierdzenie znacznika czasu</string>
<string name="ots_info_description">Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie.</string>
@@ -3015,6 +3120,83 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="nowhere_link_card_art">Sztuka na „Nowhere”</string>
<string name="nowhere_link_card_forum">Forum „Nowhere”</string>
<!-- Marmot (MLS) group chats -->
<string name="marmot_groups_title">Grupy Marmot</string>
<string name="marmot_create_group">Utwórz Grupę</string>
<string name="marmot_create_group_title">Utwórz Grupę Marmot</string>
<string name="marmot_create_group_footer">Zostanie utworzona nowa grupa MLS. Następnie możesz dodać członków.</string>
<string name="marmot_group_default_name">Grupy Marmot</string>
<string name="marmot_group_fallback_name">Grupa %1$s…</string>
<string name="marmot_user_fallback_name">%1$s…</string>
<string name="marmot_tab_known">Znane</string>
<string name="marmot_tab_known_count">Znane (%1$d)</string>
<string name="marmot_no_groups">Brak grup</string>
<string name="marmot_no_groups_desc">Utwórz grupę lub zaakceptuj zaproszenie.</string>
<string name="marmot_no_invitations">Brak zaproszeń</string>
<string name="marmot_no_invitations_desc">Gdy ktoś doda cię do grupy, będzie to widoczne tutaj, dopóki nie odpowiesz.</string>
<string name="marmot_no_messages_yet">Brak wiadomości</string>
<plurals name="marmot_message_count">
<item quantity="one">%1$d wiadomość</item>
<item quantity="few">%1$d wiadomości</item>
<item quantity="many">%1$d wiadomości</item>
<item quantity="other">%1$d wiadomości</item>
</plurals>
<plurals name="marmot_member_count">
<item quantity="one">%1$d członek</item>
<item quantity="few">%1$d członków</item>
<item quantity="many">%1$d członków</item>
<item quantity="other">%1$d członków</item>
</plurals>
<string name="marmot_group_name">Nazwa grupy</string>
<string name="marmot_group_name_placeholder">Wpisz nazwę grupy</string>
<string name="marmot_group_description_placeholder">Dodaj opis grupy (opcjonalnie)</string>
<string name="marmot_edit_info_footer">Zmiany zostaną zatwierdzone w grupie za pośrednictwem MLS i przekazane wszystkim członkom.</string>
<string name="marmot_group_info_updated">Informacje o grupie zaktualizowane</string>
<string name="marmot_failed_to_update">Nie udało się zaktualizować: %1$s</string>
<string name="marmot_failed_to_create_group">Nie udało się utworzyć grupy: %1$s</string>
<string name="marmot_keypackage_relays_not_set_title">Nie ustawiono transmiterów KeyPackage</string>
<string name="marmot_keypackage_relays_not_set_message">Nie masz jeszcze listy transmiterów KeyPackage (MIP-00). Lista ta informuje innych użytkowników, gdzie opublikowano Twój KeyPackage, dzięki czemu mogą zapraszać Cię do czatów grupowych.\n\nUżyj do tego swoich obecnych transmiterów ze skrzynki wysyłkowej?</string>
<string name="marmot_use_outbox_relays">Użyj transmiterów wychodzących</string>
<string name="marmot_skip_for_now">Na razie pomiń</string>
<string name="marmot_group_info_title">Informacje o grupie</string>
<string name="marmot_edit_group_info">Edytuj informacje o grupie</string>
<string name="marmot_leave_group">Opuść grupę</string>
<string name="marmot_this_group">ta grupa</string>
<string name="marmot_leave_group_confirm">Czy na pewno chcesz opuścić grupę „%1$s”? Nie będziesz już otrzymywać wiadomości od tej grupy.</string>
<string name="marmot_failed_to_leave_group">Nie udało się opuścić grupy: %1$s</string>
<string name="marmot_adding_user">Dodawanie %1$s…</string>
<string name="marmot_failed_to_add_user">Nie udało się dodać %1$s: %2$s</string>
<string name="marmot_unknown_error">nieznany błąd</string>
<string name="marmot_keypackage_required">Aby można było dodać dany pakiet, musi on zostać opublikowany jako KeyPackage (kind:30443).</string>
<string name="marmot_add_to_group">Dodaj do grupy</string>
<string name="marmot_add_member">Dodaj członka</string>
<string name="marmot_add_member_action">Dodaj Członka</string>
<string name="marmot_add_member_placeholder">Nazwa, npub lub NIP-05</string>
<string name="marmot_member_suffix_you"> (Ty)</string>
<string name="marmot_member_suffix_admin"> - administrator</string>
<string name="marmot_member_removed">Członek usunięty</string>
<string name="marmot_failed_to_remove_member">Nie udało się usunąć członka: %1$s</string>
<string name="marmot_remove_member">Usuń Członka</string>
<string name="marmot_remove_member_confirm">Czy na pewno chcesz usunąć \"%1$s\" z tej grupy?</string>
<string name="marmot_grant">Przyznaj</string>
<string name="marmot_grant_admin_privileges">Przyznaj uprawnienia admina</string>
<string name="marmot_grant_admin_title">Przyznaj uprawnienia administratora</string>
<string name="marmot_grant_admin_confirm">Zrób \"%1$s\" administratorem tej grupy? Administratorzy mogą dodawać lub usuwać członków, zmieniać informacje o grupie i przyznawać uprawnienia administratora innym członkom.</string>
<string name="marmot_admin_granted">Przyznane uprawnienia administracyjne</string>
<string name="marmot_failed_to_grant_admin">Nie udało się przyznać uprawnień admina: %1$s</string>
<string name="marmot_revoke">Wycofaj</string>
<string name="marmot_revoke_admin_privileges">Cofnij uprawnienia administratora</string>
<string name="marmot_revoke_admin_title">Cofnij uprawnienia administratora</string>
<string name="marmot_revoke_admin_confirm">Odbierz uprawnienia administratora od „%1$s”? Użytkownik ten pozostanie członkiem grupy.</string>
<string name="marmot_admin_revoked">Uprawnienia administratora cofnięte</string>
<string name="marmot_failed_to_revoke_admin">Nie udało się cofnąć upragnień admina: %1$s</string>
<string name="marmot_relays_header">Transmitery</string>
<string name="marmot_relay_no_events">brak wydarzeń</string>
<string name="marmot_relay_last_event">ostatnie wydarzenie%1$s</string>
<string name="marmot_not_a_member">Nie jest członkiem tej grupy</string>
<string name="copy">Kopiuj</string>
<string name="ots_reset_to_auto_select">Zresetuj do automatycznego wyboru</string>
<string name="dvm_pay_invoice_from_dvm">Zapłać fakturę z DVM</string>
<string name="dvm_pay_amount_to_dvm">Zapłać %1$s satoszy na DVM</string>
<string name="the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct">Usługa lightning odbiorcy w %1$s jest niedostępna. Została obliczona na podstawie adresu lightning \"%2$s\". Błąd: %3$s. Sprawdź, czy serwer jest gotowy i czy adres lightning jest poprawny</string>
<string name="could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception">Nie można rozwiązać %1$s. Sprawdź, czy jesteś połączony, czy serwer jest gotowy i czy lightning adres %2$s jest poprawny.\n\nWyjątkiem było: %3$s</string>
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika</string>
@@ -69,7 +69,7 @@ Prijavi se s privatnim ključem za všečkanje sporočila</string>
<string name="chat_zap_amount_suffix">zapnil %1$s satov</string>
<string name="chat_zap_anonymous">Anonimno</string>
<string name="chat_raid_is_raiding">izvaja vdor</string>
<string name="chat_clip_created_a_clip">ustvarjen izsek</string>
<string name="chat_clip_created_a_clip">izsek ustvarjen</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Uporabljaš javni ključ in javni ključi omogočajo le branje.
Prijavite se s privatnim ključem, da boste lahko pošiljali Zape</string>
<string name="login_with_a_private_key_to_be_able_to_follow">Uporabljaš javni ključ in javni ključi omogočajo le branje.
@@ -658,9 +658,96 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="exercise_emom">EMOM</string>
<string name="exercise_amrap">AMRAP</string>
<string name="software_apps">Aplikacije</string>
<string name="route_software_apps">Applikacije</string>
<string name="napplets">nApplets</string>
<string name="nsites">nSites</string>
<string name="nsite_none_found">nSites še niso najdena.</string>
<string name="browser">Brskalnik</string>
<string name="browser_address_hint">Išči ali vpiši naslov</string>
<string name="browser_reload">Ponovno naloži</string>
<string name="browser_tor_on">Nalaganje prek Tor. Tapnite za uporabo odprtega spleta.</string>
<string name="browser_tor_off">Nalaganje prek odprtega spleta. Tapnite za uporabo Tor.</string>
<string name="browser_unsupported">Vgrajeni brskalnik zahteva Android 11 ali novejši.</string>
<string name="embedded_tab_load_failed">Te aplikacije ni bilo mogoče naložiti.</string>
<string name="browser_go">Odpri</string>
<string name="browser_clear">Počisti</string>
<string name="browser_favorites">Priljubljene</string>
<string name="browser_recent_options">Možnosti</string>
<string name="browser_recent_remove">Odstrani iz zgodovine</string>
<string name="favorite_apps">Priljubljene aplikacije</string>
<string name="favorite_apps_empty">Še ni priljubljenih aplikacij. Odprite spletni odjemalec ali nsite in tapnite zvezdico za pripenjanje.</string>
<string name="favorite_app_add">dodaj med priljubljene</string>
<string name="favorite_app_remove">Odstrani iz priljubljenih</string>
<string name="favorite_app_open_window">Odpri v novem oknu</string>
<string name="favorite_app_access_title">Do česa lahko ta aplikacija dostopa</string>
<string name="favorite_app_access_static">Deluje v peskovniku brez posebnega dostopa do vašega računa.</string>
<string name="favorite_app_access_show">Dostop do</string>
<string name="favorite_app_network_tor">Nalaganje prek Tor</string>
<string name="favorite_app_network_open">Nalaganje prek odprtega spleta</string>
<string name="favorite_app_unavailable">Ta aplikacija še ni naložena. Odprite jo s kartice ali poskusite znova čez trenutek.</string>
<string name="favorite_notice_published">Objavljeno na relejih</string>
<string name="favorite_notice_uploaded">Datoteka naložena</string>
<string name="favorite_notice_paid">Plačilo izvedeno</string>
<string name="favorite_app_still_loading">Ta aplikacija še ni naložena. Poskusite znova kasneje.</string>
<string name="favorite_app_recent">Nedavno</string>
<string name="napplet_permissions">nApplet dovoljenja</string>
<string name="napplet_manage_permissions">Upravljanje dovoljenj</string>
<string name="napplet_permissions_empty">Še ni dovoljenj za nApplet</string>
<string name="napplet_permissions_empty_subtitle">Tukaj bodo prikazana dovoljenja, ki jih dodelite nAppletom.</string>
<string name="napplet_permissions_forget">Pozabi ta nApplet</string>
<string name="napplet_permissions_blocked">Blokirano</string>
<string name="napplet_permissions_revoke">Prekliči</string>
<string name="napplet_untitled">Neimenovan nApplet</string>
<string name="napplet_none_found">Še ni najdenih nobenih nAppletov.</string>
<string name="napplet_fallback_title">nApplet%1$s…</string>
<!-- Napplet sandbox chrome (host top bar + live action notices) -->
<!-- Napplet capability names -->
<string name="napplet_cap_shell">Shell</string>
<string name="napplet_cap_identity">Identiteta</string>
<string name="napplet_cap_keys">Tipkovne bližnjice</string>
<string name="napplet_cap_relay">Releji</string>
<string name="napplet_cap_storage">Shramba</string>
<string name="napplet_cap_value">Plačila</string>
<string name="napplet_cap_resource">Mreža</string>
<string name="napplet_cap_upload">Prenosi</string>
<string name="napplet_cap_shell_desc">Ta nApplet želi preveriti, katere zmogljivosti so na voljo</string>
<string name="napplet_cap_identity_desc">Prebere vaš javni ključ</string>
<string name="napplet_cap_keys_desc">Poveži tipkovne bližnjice</string>
<string name="napplet_cap_relay_desc">Branje, podpisovanje in objavljanje dogodkov</string>
<string name="napplet_cap_storage_desc">Lastna zasebna shramba</string>
<string name="napplet_cap_value_desc">Plačaj Lightning račune</string>
<string name="napplet_cap_resource_desc">Pridobi spletne in Blossom vire</string>
<string name="napplet_cap_upload_desc">Nalaganje datotek na vaš medijski strežnik</string>
<string name="napplet_cap_theme">Tema</string>
<string name="napplet_cap_notify">Obvestila</string>
<string name="napplet_cap_inc">Sporočanje</string>
<string name="napplet_cap_theme_desc">Prilagoditev barv aplikaciji</string>
<string name="napplet_cap_notify_desc">Prikaz obvestil</string>
<string name="napplet_cap_inc_desc">Izmenjava sporočil z drugimi nAppleti</string>
<!-- Napplet consent dialog -->
<string name="napplet_consent_capability">Zmogljivost: %1$s</string>
<string name="napplet_consent_allow_always">Vedno dovoli</string>
<string name="napplet_consent_allow_once">Dovoli enkrat</string>
<string name="napplet_consent_deny_always">Nikoli ne dovoli</string>
<string name="napplet_consent_not_now">Ne zdaj</string>
<string name="napplet_consent_get_pubkey">Ta nApplet želi prebrati vaš javni ključ.</string>
<string name="napplet_consent_identity_read">Ta nApplet želi prebrati vaše podatke o profilu in računu.</string>
<string name="napplet_consent_publish">Ta nApplet želi v vašem imenu podpisati in objaviti dogodek vrste %1$d.</string>
<string name="napplet_consent_publish_preview">Ta nApplet želi v vašem imenu podpisati in objaviti dogodek vrste %1$d:</string>
<string name="napplet_consent_publish_encrypted">Ta nApplet želi v vašem imenu poslati šifriran dogodek.</string>
<string name="napplet_consent_query">Ta nApplet želi prebrati dogodke z vaših relejev.</string>
<string name="napplet_consent_storage">Ta nApplet želi uporabiti svojo zasebno shrambo.</string>
<string name="napplet_consent_pay">Ta nApplet želi plačati Lightning račun.</string>
<string name="napplet_consent_resource">Ta nApplet želi pridobiti spletni vir.</string>
<string name="napplet_consent_upload">Ta nApplet želi naložiti datoteko na vaš medijski strežnik.</string>
<string name="napplet_consent_notify">Ta nApplet vam želi prikazati obvestila.</string>
<string name="napplet_consent_sign">Ta stran želi podpisati dogodek vrste (kind) %1$d, z vašim Nostr ključem.</string>
<plurals name="napplet_consent_pay_amount">
<item quantity="one">Ta nApplet želi plačati Lightning račun za %1$d sat.</item>
<item quantity="two">Ta nApplet želi plačati Lightning račun za %1$d sata.</item>
<item quantity="few">Ta nApplet želi plačati Lightning račun za %1$d sate.</item>
<item quantity="other">Ta nApplet želi plačati Lightning račun za %1$d satov.</item>
</plurals>
<string name="nip82_repository_label">Vir: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Prenos</string>
@@ -2249,9 +2336,13 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="git_repo_section_topics">Teme</string>
<string name="git_repo_personal_fork">Osebni fork</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>
<string name="profile_tab_apps">Aplikacije in spletna mesta</string>
<string name="nsite_root_site">Izhodiščno spletišče</string>
<string name="nsite_source">Vir:</string>
<string name="nsite_servers">Strežniki:</string>
<string name="nsite_open">Odpri</string>
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Dokaz časovnega žiga</string>
<string name="ots_info_description">Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas.</string>
@@ -628,6 +628,37 @@
<string name="napplets">nApplets</string>
<string name="nsites">nSites</string>
<string name="nsite_none_found">尚未找到nSite。</string>
<string name="browser">浏览器</string>
<string name="browser_address_hint">搜索或输入地址</string>
<string name="browser_reload">重新加载</string>
<string name="browser_console_title_short">控制台</string>
<string name="browser_console_title">控制台(%1$d)</string>
<string name="browser_console_clear">清除</string>
<string name="browser_tor_on">正通过 Tor 加载。点击使用 open web。</string>
<string name="browser_tor_off">正通过 open web 加载 Tor。点击使用 Tor。</string>
<string name="browser_unsupported">应用内浏览器需要 Android 11 或更高版本。</string>
<string name="embedded_tab_load_failed">无法加载此应用。</string>
<string name="browser_go">打开</string>
<string name="browser_clear">清除</string>
<string name="browser_favorites">收藏夹</string>
<string name="browser_recent_options">选项</string>
<string name="browser_recent_remove">从历史记录中删除</string>
<string name="favorite_apps">喜欢的应用</string>
<string name="favorite_apps_empty">还没有喜欢的应用。打开 web 客户端或 nsite 并点击⭐来固定它。</string>
<string name="favorite_app_add">添加至收藏夹</string>
<string name="favorite_app_remove">从收藏中移除</string>
<string name="favorite_app_open_window">在自己的窗口打开</string>
<string name="favorite_app_access_title">此应用可以访问什么</string>
<string name="favorite_app_access_static">在沙盒中运行,没有对账户的特殊访问权。</string>
<string name="favorite_app_access_show">此应用可以访问什么</string>
<string name="favorite_app_network_tor">通过 Tor 加载。</string>
<string name="favorite_app_network_open">通过 open web 加载。</string>
<string name="favorite_app_unavailable">此应用尚未加载。请从其卡片打开它,或稍后再试。</string>
<string name="favorite_notice_published">已发布到中继</string>
<string name="favorite_notice_uploaded">上传了一个文件</string>
<string name="favorite_notice_paid">支付了一笔款项</string>
<string name="favorite_app_still_loading">该应用尚未加载。稍后再试。</string>
<string name="favorite_app_recent">最近</string>
<string name="napplet_permissions">nApplet 权限</string>
<string name="napplet_manage_permissions">管理权限</string>
<string name="napplet_permissions_empty">尚无nApplet 权限</string>
@@ -0,0 +1,43 @@
/*
* 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
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.urlRoute
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.net.URLEncoder
class URIParserTest {
@Test
fun parsesEncodedUrlRoutes() {
val url = "HTTPS://Example.com/path?b=2&a=1#section"
val route = urlRoute("nostr:url?id=${URLEncoder.encode(url, Charsets.UTF_8.name())}")
assertEquals(Route.Url("https://example.com/path?b=2&a=1"), route)
}
@Test
fun ignoresMalformedEncodedUrlRoutes() {
assertNull(urlRoute("nostr:url?id=%"))
}
}
@@ -0,0 +1,50 @@
/*
* 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.url.datasource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class FilterPostsByUrlTest {
@Test
fun normalizesUrlAndQueriesNip22UrlScope() {
val relay = NormalizedRelayUrl("wss://relay.example/")
val filters = filterPostsByUrl("HTTPS://Example.com/path?b=2&a=1#section", setOf(relay), null)
assertEquals(1, filters.size)
assertEquals(relay, filters[0].relay)
assertEquals(CommentKinds, filters[0].filter.kinds)
assertEquals(mapOf("I" to listOf("https://example.com/path?b=2&a=1")), filters[0].filter.tags)
assertEquals(100, filters[0].filter.limit)
}
@Test
fun returnsNoFiltersForMalformedUrls() {
val relay = NormalizedRelayUrl("wss://relay.example/")
val filters = filterPostsByUrl("https://", setOf(relay), null)
assertTrue(filters.isEmpty())
}
}
+12 -12
View File
@@ -76,6 +76,12 @@
"tag": "v1.12.6",
"since": "2026-06-19T19:01:59-04:00",
"translators": [
{
"user": "maxblake2015",
"languages": [
"Polish"
]
},
{
"user": "rajs19420616",
"languages": [
@@ -89,15 +95,9 @@
]
},
{
"user": "summoner001",
"user": "StellarStoic",
"languages": [
"Hungarian"
]
},
{
"user": "maxblake2015",
"languages": [
"Polish"
"Slovenian"
]
},
{
@@ -107,15 +107,15 @@
]
},
{
"user": "BitByBit21",
"user": "summoner001",
"languages": [
"Spanish"
"Hungarian"
]
},
{
"user": "StellarStoic",
"user": "BitByBit21",
"languages": [
"Slovenian"
"Spanish"
]
},
{
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.napplethost
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.sha256.sha256
import java.io.File
@@ -54,7 +55,9 @@ class NappletBlobCache(
dir.mkdirs()
val tmp = File(dir, "$sha256.tmp.${System.nanoTime()}")
tmp.writeBytes(bytes)
if (!tmp.renameTo(target)) tmp.delete()
if (!tmp.renameTo(target) && !tmp.delete()) {
Log.w("NappletBlobCache") { "Failed to delete leftover temp file ${tmp.absolutePath} after a failed rename" }
}
}
}
@@ -597,8 +597,28 @@ class NappletBrowserActivity : ComponentActivity() {
liveUrl = startUrl,
onNavigate = { loadAddress(it) },
onConsole = { consolePanel?.toggle() },
isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
onFavoriteToggle = { url, _ -> sendFavoriteToggle(url) },
).also { controlSheet = it }
private fun sendFavoriteToggle(url: String) {
val host =
runCatching {
android.net.Uri
.parse(url)
.host
}.getOrNull()?.takeIf { it.isNotBlank() } ?: url
val msg =
Message.obtain(null, NappletIpc.MSG_TOGGLE_WEB_FAVORITE).apply {
data =
Bundle().apply {
putString(NappletIpc.KEY_FAVORITE_URL, url)
putString(NappletIpc.KEY_FAVORITE_LABEL, host)
}
}
if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg)
}
private fun buildConsolePanel(): View =
NappletConsolePanel(this).also {
it.onClearCallback = { controlSheet?.updateConsoleCount(0) }
@@ -655,6 +675,7 @@ class NappletBrowserActivity : ComponentActivity() {
private const val EXTRA_USE_TOR = "useTor"
private const val EXTRA_TITLE = "title"
private const val EXTRA_THEME = "theme"
private const val EXTRA_IS_FAVORITE = "isFavorite"
fun intent(
context: Context,
@@ -663,6 +684,7 @@ class NappletBrowserActivity : ComponentActivity() {
useTor: Boolean,
title: String = "",
theme: String = "SYSTEM",
isFavorite: Boolean = false,
): Intent =
Intent()
.setClassName(context, "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity")
@@ -671,6 +693,7 @@ class NappletBrowserActivity : ComponentActivity() {
.putExtra(EXTRA_USE_TOR, useTor)
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_THEME, theme)
.putExtra(EXTRA_IS_FAVORITE, isFavorite)
// Distinct task identity per URL for documentLaunchMode=intoExisting.
.setData(Uri.parse(url))
}
@@ -67,6 +67,9 @@ class NappletControlSheet(
// When non-null, a "Console" row is added to the pull-down sheet. The callback toggles the
// browser's console log panel; the count label is updated via [updateConsoleCount].
private val onConsole: (() -> Unit)? = null,
// When non-null, a favorite toggle row is shown; called with the current URL and new isFavorite state.
isFavoriteInitially: Boolean = false,
private val onFavoriteToggle: ((url: String, isFavorite: Boolean) -> Unit)? = null,
) : LinearLayout(context) {
private val onSurface = resolveThemeColor(android.R.attr.textColorPrimary)
private val dimmed = resolveThemeColor(android.R.attr.textColorSecondary)
@@ -75,6 +78,7 @@ class NappletControlSheet(
private var expanded = false
private var torOn = torInitiallyOn
private var currentUrl = liveUrl
private var isFavorite = isFavoriteInitially
private val panel: LinearLayout
private var torLabel: TextView? = null
@@ -82,6 +86,7 @@ class NappletControlSheet(
private var addressField: EditText? = null
private var securityGlyph: TextView? = null
private var consoleLabel: TextView? = null
private var favoriteLabel: TextView? = null
init {
orientation = VERTICAL
@@ -156,6 +161,35 @@ class NappletControlSheet(
},
)
}
onFavoriteToggle?.let {
val label =
TextView(context).apply {
text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add)
setTextColor(onSurface)
textSize = 15f
setPadding(dp(8), 0, 0, 0)
}
favoriteLabel = label
addView(
LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), dp(10), dp(8), dp(10))
isClickable = true
setOnClickListener { toggleFavorite() }
addView(
TextView(context).apply {
text = ""
setTextColor(dimmed)
textSize = 18f
width = dp(28)
gravity = Gravity.CENTER
},
)
addView(label)
},
)
}
}
private fun titleRow(): View =
@@ -254,6 +288,19 @@ class NappletControlSheet(
// Don't fight the user while they're editing the field.
addressField?.takeIf { !it.hasFocus() }?.setText(url)
securityGlyph?.text = securityGlyphFor(url)
// Reset favorite state for the new URL (we don't know if it's a favorite without a round-trip).
if (onFavoriteToggle != null) {
isFavorite = false
favoriteLabel?.text = context.getString(R.string.browser_favorite_add)
}
}
private fun toggleFavorite() {
val url = currentUrl?.takeIf { it.isNotBlank() } ?: return
isFavorite = !isFavorite
favoriteLabel?.text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add)
collapse()
onFavoriteToggle?.invoke(url, isFavorite)
}
private fun securityGlyphFor(url: String): String =
@@ -89,6 +89,14 @@ object NappletIpc {
*/
const val MSG_RECORD_ICON = 10
/**
* Host broker (browser mode): toggle a URL in the main-process favorites registry. Carries
* [KEY_FAVORITE_URL] and [KEY_FAVORITE_LABEL]. The broker adds the URL if it isn't already
* a favorite, or removes it if it is identical to the in-app star toggle on the home screen.
* Fire-and-forget; no reply needed.
*/
const val MSG_TOGGLE_WEB_FAVORITE = 11
const val KEY_REQUEST_ID = "requestId"
const val KEY_PAYLOAD = "payload"
@@ -107,6 +115,12 @@ object NappletIpc {
/** The bare host (e.g. `example.com`) a browser Tor choice belongs to. */
const val KEY_WEB_HOST = "webHost"
/** The full URL (e.g. `https://example.com`) to toggle as a web favorite. */
const val KEY_FAVORITE_URL = "favoriteUrl"
/** A human-readable label for the favorited URL (typically the host). */
const val KEY_FAVORITE_LABEL = "favoriteLabel"
/** Boolean: this sandbox surface is now foreground (true) or backgrounded (false). */
const val KEY_FOREGROUND = "foreground"
@@ -34,6 +34,10 @@
<string name="napplet_net_tor_label">Loads over Tor</string>
<string name="napplet_net_open_label">Loads over the open web</string>
<!-- Favorite toggle in the pull-down sheet -->
<string name="browser_favorite_add">Add to favorites</string>
<string name="browser_favorite_remove">Remove from favorites</string>
<!-- Loading / unavailable screens -->
<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>
@@ -43,6 +43,11 @@ class UrlId(
fun toScope(url: String) = Rfc3986.normalizeAndRemoveFragment(url)
fun toScopeOrNull(url: String) =
runCatching { toScope(url) }
.getOrNull()
?.takeIf { parse(it) != null }
fun toKind(url: String) = KIND
fun match(