refactor: cut shared-screen blockers (step 6 wave 3 seams)

- String helpers: files that only read Compose resources import the commons stringRes, not the
  app's Android-resource overloads. browser_address_hint and napplet_untitled get Compose copies
  (every locale) so the browser and connected-apps screens stop reading commons' Android R.
- ReactionsRow/LikeReaction/ZapReaction and LoadCityName are imported through the note-platform
  shims instead of the app implementations.
- QR: the drawer moves to commonsUI with the encoding behind encodeQrMatrix (zxing core on
  JVM/Android, nothing yet on iOS); KeepScreenBrightAndAwake becomes expect/actual; ScanOutcome is
  shared and the camera scanner is an AppPlatform slot.
- AppPlatform gains the app launcher and the Concord/napplet/manifest/favicon image models.
  favoriteCoordinateOf moves to commons.
- New AppServices port (favorites, browser history and icons, napplet and signer permissions, Tor
  settings) installed as LocalAppServices; AccountViewModelHost gains the Tor relay evaluation and
  NIP-42 auth state. Screens read these instead of Amethyst.instance.
- uriToRoute and its helpers leave MainActivity for UriToRoute.kt and decode with the KMP
  UrlEncoder; findQueryParameterValue leaves AppNavigation.
- Settings rows split out of AppSettingsScreen; formatGrouped replaces NumberFormat; Uuid replaces
  java.util.UUID; javaClass and toBigDecimalOrNull swapped for KMP forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-29 02:33:33 +00:00
parent 6904bba33c
commit 642b59c9d1
112 changed files with 1540 additions and 779 deletions
@@ -27,6 +27,7 @@ import android.os.Bundle
import android.widget.Toast
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.favoriteCoordinateOf
import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.napplet.NappletLauncher
@@ -212,17 +213,6 @@ object FavoriteAppLauncher {
}
}
/**
* The addressable coordinate `kind:pubkey:dtag` used to key an nsite/napplet favorite. Stored
* instead of the content hash so the favorite survives routine code/manifest updates.
*/
fun coordinateOf(event: Event): String {
val dTag =
when (event) {
is NamedNappletEvent -> event.identifier()
is NamedSiteEvent -> event.identifier()
else -> ""
}
return "${event.kind}:${event.pubKey}:$dTag"
}
/** See [favoriteCoordinateOf]. */
fun coordinateOf(event: Event): String = favoriteCoordinateOf(event)
}
@@ -27,51 +27,19 @@ import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeForPointer
import com.vitorpamplona.amethyst.commons.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.NotificationRelayService
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.net.URLDecoder
class MainActivity : AppCompatActivity() {
companion object {
@@ -155,339 +123,3 @@ class MainActivity : AppCompatActivity() {
super.onDestroy()
}
}
private const val NOSTR_URI_PREFIX = "nostr:"
fun isNotificationRoute(uri: String) = uri.startsWith("notifications", true) || uri.startsWith("nostr:notifications", true)
fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWith("nostr:hashtag?id=")
/**
* A markdown link target that is a bare hashtag fragment, e.g. the `#NostrMultiplayerGames` in
* `[Games](#NostrMultiplayerGames)`. Returns the tag without the leading `#`, or null when the
* uri is anything else (full URLs with anchors don't start with `#`). Reuses the same character
* class that linkifies #hashtags in plain text, so validity matches how tags parse everywhere else.
*/
fun fragmentHashtagOrNull(uri: String): String? {
val match = RichTextParser.hashTagsPattern.matchEntire(uri) ?: return null
if (!match.groups[2]?.value.isNullOrEmpty()) return null
return match.groups[1]?.value
}
fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=")
/**
* A private chatroom, addressed by its participants rather than by a message.
* Posted by DM notifications — see [NotificationRoutes.chatroomUri] for why a DM
* cannot deep-link through its own note.
*/
fun isChatroomRoute(uri: String) = uri.startsWith("chatroom?id=") || uri.startsWith("nostr:chatroom?id=")
/**
* Note what this deliberately does *not* do: register the room on the account.
*
* `routeToMessage` would, and that is right for the in-app callers, but wrong here twice over.
* `uriToRoute` runs against whichever account is current, *before* `?account=` is read and the
* switch happens (`AppNavigation.NavigateIfIntentRequested`), so a DM notification for account B
* tapped while A is on screen would insert an empty conversation into **A**'s message list. And
* `nostr:` is an exported, browsable scheme, so any web page could post
* `nostr:chatroom?id=<hex>&account=…` and inject a room of its choosing.
*
* Nothing needs it: `ChatroomFeedFilter.chatroom()` calls `getOrCreatePrivateChatroom` when the
* screen actually opens, by which point the switch has happened and the room lands on the right
* account.
*
* The ids are still checked here — a room key is a set of pubkeys, so anything that isn't one is
* not a room, and a bad deep link should resolve to nothing rather than to an unopenable screen.
*/
fun chatroomRoute(uri: String): Route? {
val users =
uri
.findQueryParameterValue("id")
?.split(',')
?.map { it.trim() }
?.takeIf { it.isNotEmpty() && it.all(::isPubKeyHex) }
?.toSet() ?: return null
return Route.Room(ChatroomKey(users))
}
/** A bare 32-byte lowercase-hex pubkey, the only thing a chatroom key is made of. */
private fun isPubKeyHex(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
/**
* A NIP-17 private note or reply, addressed by the rumor's own id because its `nevent`
* names the undeliverable gift wrap instead — see [NotificationRoutes.privateNoteUri].
*/
fun isPrivateNoteRoute(uri: String) = uri.startsWith("privatenote?id=") || uri.startsWith("nostr:privatenote?id=")
fun privateNoteRoute(uri: String): Route? {
val id = uri.findQueryParameterValue("id") ?: return null
return Route.EventRedirect(id, isPrivate = true)
}
fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=")
/**
* The Connected Apps permission-detail route for an app coordinate (`pubkey:dtag` for a napplet/nsite,
* `browser:<origin>` for a web client). Fired by the sandbox host so a running full-screen surface can
* jump straight to its editable permissions; the coordinate is URL-encoded into the [coordinate] param.
*/
fun connectedAppRoute(uri: String): Route.ConnectedAppDetail? {
val coordinate =
runCatching {
val raw = java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("coordinate") ?: return null
URLDecoder.decode(raw, Charsets.UTF_8.name())
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
return Route.ConnectedAppDetail(coordinate)
}
fun urlRoute(uri: String): Route.Url? {
val url =
runCatching {
val rawUrl = java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).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:")
/**
* Tapping the always-on service notification. That notification's whole subject is the relay
* connections it is holding open, so it lands on the screen that explains them rather than on
* whatever tab happened to be last open.
*/
fun isActiveSubscriptionsRoute(uri: String) = uri.startsWith("activesubs", true) || uri.startsWith("nostr:activesubs", true)
/**
* The launcher shortcut (res/xml/shortcuts.xml) fires `amethyst:scanqr`, caught by MainActivity's
* existing scheme filter. A pseudo-uri rather than a hardcoded component, because debug and
* benchmark builds carry an applicationId suffix a hardcoded targetPackage would miss, and
* manifest placeholders are not substituted into resource XML.
*
* `nostr:scanqr` is still accepted so an older pinned shortcut keeps working, but the shortcut
* itself uses our own scheme: an implicit `nostr:` VIEW intent can be answered by any Nostr client
* installed alongside us.
*/
fun isScanQrRoute(uri: String) =
uri.equals("scanqr", true) ||
uri.equals("amethyst:scanqr", true) ||
uri.equals("nostr:scanqr", true)
private val MARMOT_HEX = Regex("^[0-9a-fA-F]+$")
fun uriToRoute(
uri: String,
account: Account,
): Route? {
if (isNotificationRoute(uri)) {
val scrollTo = uri.findQueryParameterValue("scrollTo")
return Route.Notification(scrollToEventId = scrollTo)
}
if (isActiveSubscriptionsRoute(uri)) {
return Route.ActiveSubscriptions
}
if (isScanQrRoute(uri)) {
return Route.QRDisplay(account.signer.pubKey, startScanning = true)
}
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase())
}
fragmentHashtagOrNull(uri)?.let {
return Route.Hashtag(it.lowercase())
}
if (isUrlRoute(uri)) {
return urlRoute(uri)
}
if (isChatroomRoute(uri)) {
return chatroomRoute(uri)
}
if (isPrivateNoteRoute(uri)) {
return privateNoteRoute(uri)
}
if (isConnectedAppRoute(uri)) {
return connectedAppRoute(uri)
}
// A scanned/opened `nostrconnect://` offer is an app asking to connect to our signer: open the
// NIP-46 signer screen and let it run the pairing (it enables the signer as part of connecting).
if (uri.startsWith(NostrConnectURI.NOSTRCONNECT_SCHEME)) {
return Route.Nip46Signer(connectUri = uri)
}
relayGroupInviteRoute(uri)?.let { return it }
concordInviteRoute(uri)?.let { return it }
buzzInviteRoute(uri)?.let { return it }
val parsedNip19 = Nip19Parser.uriToRoute(uri)
val nip19 = parsedNip19?.entity
if (nip19 != null) {
LocalCache.consume(nip19)
val route =
when (nip19) {
is NPub -> {
Route.Profile(nip19.hex)
}
is NProfile -> {
Route.Profile(nip19.hex)
}
is NNote -> {
Route.Note(nip19.hex)
}
is NEvent -> {
val note = LocalCache.getOrCreateNote(nip19.hex)
// Only fall back to the pointer's own kind while the body is missing: once the
// event is cached it may belong somewhere the kind alone can't name (a channel,
// a chatroom), and routeFor knows that.
if (note.event == null) {
routeForPointer(nip19.kind, nip19.hex) ?: Route.EventRedirect(nip19.hex)
} else {
routeFor(note = note, loggedIn = account) ?: Route.EventRedirect(nip19.hex)
}
}
is NAddress -> {
relayGroupDirectRoute(nip19, parsedNip19.additionalChars)
?: routeFor(
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
loggedIn = account,
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
}
is NEmbed -> {
val noteEvent = nip19.event
if (noteEvent is AddressableEvent) {
routeFor(
note = LocalCache.getOrCreateAddressableNote(noteEvent.address()),
loggedIn = account,
) ?: Route.EventRedirect(noteEvent.addressTag())
} else {
routeFor(
note = LocalCache.getOrCreateNote(nip19.event.id),
loggedIn = account,
) ?: Route.EventRedirect(nip19.event.id)
}
}
else -> {
null
}
}
if (route != null) {
return route
}
}
if (isMarmotGroupRoute(uri)) {
// marmot:<groupHex>?account=<npub>
val groupHex =
uri
.removePrefix("marmot:")
.substringBefore("?")
.substringBefore("&")
if (groupHex.matches(MARMOT_HEX)) {
return Route.MarmotGroupChat(groupHex)
}
}
if (isWalletConnectRoute(uri)) {
try {
val url = UriParser(uri)
val nip47Uri = url.getQueryParameter("value")?.firstOrNull()
if (nip47Uri != null) {
Nip47WalletConnect.parse(nip47Uri)
return Route.WalletAddNwc(nip47Uri)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
try {
Nip47WalletConnect.parse(uri)
return Route.WalletAddNwc(uri)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
return null
}
/**
* Direct route for an `naddr` whose event hasn't arrived in [LocalCache] yet. When a notification
* is tapped (or a `nostr:naddr…` deep link arrives) for a calendar appointment we know is kind
* 31922/31923, route straight to the dedicated detail screen instead of bouncing through
* [Route.EventRedirect]. The detail screen issues its own per-event subscription, so the user
* sees the calendar-specific loading placeholder while the event arrives, not the generic
* redirect screen.
*/
private fun calendarDirectRoute(nip19: NAddress): Route? =
when (nip19.kind) {
CalendarTimeSlotEvent.KIND,
CalendarDateSlotEvent.KIND,
-> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag)
else -> null
}
/**
* Direct route for a NIP-29 group `naddr` (kind 39000) shared as a link. The group
* only exists on its host relay, so the naddr's relay hint is mandatory — the group
* id alone can't be resolved. With a hint we open the group chat straight away
* (cold start included); without one there's nowhere to look, so fall through.
*/
private fun relayGroupDirectRoute(
nip19: NAddress,
additionalChars: String?,
): Route? {
if (nip19.kind != GroupMetadataEvent.KIND) return null
val relay = nip19.relay.firstOrNull() ?: return null
// The spec allows an invite code appended as `naddr1…?invite=<code>`; it arrives
// in the trailing chars after the bech32 body. Carry it so the group auto-joins.
val inviteCode = GroupNAddrInvite.parse(additionalChars)
return Route.RelayGroup(nip19.dTag, relay.url, inviteCode = inviteCode)
}
/**
* Direct route for a NIP-29 group invite link in the de-facto `<relay>'<groupId>[?code=<code>]`
* form shared by Wisp and 0xchat (e.g. `wss://groups.0xchat.com'abc123`). The link carries its
* own host relay, so we can open the group straight away. Returns null for anything that isn't
* this exact shape, so it's safe to try before the nostr-entity parser.
*/
private fun relayGroupInviteRoute(uri: String): Route? {
val link = GroupInviteLink.parse(uri.removePrefix(NOSTR_URI_PREFIX)) ?: return null
return Route.RelayGroup(link.groupId, link.relayUrl.url, inviteCode = link.code)
}
/**
* A shared Concord invite URL (`…/invite/<naddr>#<fragment>`). Cheap substring gates
* keep the parse off the hot path; the whole URL (fragment included) is carried into
* the route so the redeem flow still has the unlock token.
*/
private fun concordInviteRoute(uri: String): Route? =
if (uri.contains("/invite/") && uri.contains('#') && ConcordActions.parseInviteLink(uri) != null) {
Route.ConcordInvite(uri)
} else {
null
}
/**
* A Buzz workspace invite (`https://<host>/invite/<token>`) — a plain `/invite/` https URL with
* no fragment, so disjoint from the Concord shape above. Opens the in-app join flow
* ([Route.BuzzInvite]) instead of the external browser so the claim signs with the user's key.
*/
private fun buzzInviteRoute(uri: String): Route? =
if (uri.contains("/invite/") && BuzzInviteLink.parse(uri) != null) {
Route.BuzzInvite(uri)
} else {
null
}
@@ -0,0 +1,388 @@
/*
* 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
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeForPointer
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI
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.UriParser
import com.vitorpamplona.quartz.utils.UrlEncoder
import kotlinx.coroutines.CancellationException
private const val NOSTR_URI_PREFIX = "nostr:"
fun isNotificationRoute(uri: String) = uri.startsWith("notifications", true) || uri.startsWith("nostr:notifications", true)
fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWith("nostr:hashtag?id=")
/**
* A markdown link target that is a bare hashtag fragment, e.g. the `#NostrMultiplayerGames` in
* `[Games](#NostrMultiplayerGames)`. Returns the tag without the leading `#`, or null when the
* uri is anything else (full URLs with anchors don't start with `#`). Reuses the same character
* class that linkifies #hashtags in plain text, so validity matches how tags parse everywhere else.
*/
fun fragmentHashtagOrNull(uri: String): String? {
val match = RichTextParser.hashTagsPattern.matchEntire(uri) ?: return null
if (!match.groups[2]?.value.isNullOrEmpty()) return null
return match.groups[1]?.value
}
fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=")
/**
* A private chatroom, addressed by its participants rather than by a message.
* Posted by DM notifications — see `NotificationRoutes.chatroomUri` for why a DM
* cannot deep-link through its own note.
*/
fun isChatroomRoute(uri: String) = uri.startsWith("chatroom?id=") || uri.startsWith("nostr:chatroom?id=")
/**
* Note what this deliberately does *not* do: register the room on the account.
*
* `routeToMessage` would, and that is right for the in-app callers, but wrong here twice over.
* `uriToRoute` runs against whichever account is current, *before* `?account=` is read and the
* switch happens (`AppNavigation.NavigateIfIntentRequested`), so a DM notification for account B
* tapped while A is on screen would insert an empty conversation into **A**'s message list. And
* `nostr:` is an exported, browsable scheme, so any web page could post
* `nostr:chatroom?id=<hex>&account=…` and inject a room of its choosing.
*
* Nothing needs it: `ChatroomFeedFilter.chatroom()` calls `getOrCreatePrivateChatroom` when the
* screen actually opens, by which point the switch has happened and the room lands on the right
* account.
*
* The ids are still checked here — a room key is a set of pubkeys, so anything that isn't one is
* not a room, and a bad deep link should resolve to nothing rather than to an unopenable screen.
*/
fun chatroomRoute(uri: String): Route? {
val users =
uri
.findQueryParameterValue("id")
?.split(',')
?.map { it.trim() }
?.takeIf { it.isNotEmpty() && it.all(::isPubKeyHex) }
?.toSet() ?: return null
return Route.Room(ChatroomKey(users))
}
/** A bare 32-byte lowercase-hex pubkey, the only thing a chatroom key is made of. */
private fun isPubKeyHex(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
/**
* A NIP-17 private note or reply, addressed by the rumor's own id because its `nevent`
* names the undeliverable gift wrap instead — see `NotificationRoutes.privateNoteUri`.
*/
fun isPrivateNoteRoute(uri: String) = uri.startsWith("privatenote?id=") || uri.startsWith("nostr:privatenote?id=")
fun privateNoteRoute(uri: String): Route? {
val id = uri.findQueryParameterValue("id") ?: return null
return Route.EventRedirect(id, isPrivate = true)
}
fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=")
/**
* The Connected Apps permission-detail route for an app coordinate (`pubkey:dtag` for a napplet/nsite,
* `browser:<origin>` for a web client). Fired by the sandbox host so a running full-screen surface can
* jump straight to its editable permissions; the coordinate is URL-encoded into the [coordinate] param.
*/
fun connectedAppRoute(uri: String): Route.ConnectedAppDetail? {
val coordinate =
runCatching {
val raw = uri.removePrefix(NOSTR_URI_PREFIX).findQueryParameterValue("coordinate") ?: return null
UrlEncoder.decode(raw)
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
return Route.ConnectedAppDetail(coordinate)
}
fun urlRoute(uri: String): Route.Url? {
val url =
runCatching {
val rawUrl = uri.removePrefix(NOSTR_URI_PREFIX).findQueryParameterValue("id") ?: return null
UrlEncoder.decode(rawUrl)
}.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:")
/**
* Tapping the always-on service notification. That notification's whole subject is the relay
* connections it is holding open, so it lands on the screen that explains them rather than on
* whatever tab happened to be last open.
*/
fun isActiveSubscriptionsRoute(uri: String) = uri.startsWith("activesubs", true) || uri.startsWith("nostr:activesubs", true)
/**
* The launcher shortcut (res/xml/shortcuts.xml) fires `amethyst:scanqr`, caught by MainActivity's
* existing scheme filter. A pseudo-uri rather than a hardcoded component, because debug and
* benchmark builds carry an applicationId suffix a hardcoded targetPackage would miss, and
* manifest placeholders are not substituted into resource XML.
*
* `nostr:scanqr` is still accepted so an older pinned shortcut keeps working, but the shortcut
* itself uses our own scheme: an implicit `nostr:` VIEW intent can be answered by any Nostr client
* installed alongside us.
*/
fun isScanQrRoute(uri: String) =
uri.equals("scanqr", true) ||
uri.equals("amethyst:scanqr", true) ||
uri.equals("nostr:scanqr", true)
private val MARMOT_HEX = Regex("^[0-9a-fA-F]+$")
fun uriToRoute(
uri: String,
account: Account,
): Route? {
if (isNotificationRoute(uri)) {
val scrollTo = uri.findQueryParameterValue("scrollTo")
return Route.Notification(scrollToEventId = scrollTo)
}
if (isActiveSubscriptionsRoute(uri)) {
return Route.ActiveSubscriptions
}
if (isScanQrRoute(uri)) {
return Route.QRDisplay(account.signer.pubKey, startScanning = true)
}
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase())
}
fragmentHashtagOrNull(uri)?.let {
return Route.Hashtag(it.lowercase())
}
if (isUrlRoute(uri)) {
return urlRoute(uri)
}
if (isChatroomRoute(uri)) {
return chatroomRoute(uri)
}
if (isPrivateNoteRoute(uri)) {
return privateNoteRoute(uri)
}
if (isConnectedAppRoute(uri)) {
return connectedAppRoute(uri)
}
// A scanned/opened `nostrconnect://` offer is an app asking to connect to our signer: open the
// NIP-46 signer screen and let it run the pairing (it enables the signer as part of connecting).
if (uri.startsWith(NostrConnectURI.NOSTRCONNECT_SCHEME)) {
return Route.Nip46Signer(connectUri = uri)
}
relayGroupInviteRoute(uri)?.let { return it }
concordInviteRoute(uri)?.let { return it }
buzzInviteRoute(uri)?.let { return it }
val parsedNip19 = Nip19Parser.uriToRoute(uri)
val nip19 = parsedNip19?.entity
if (nip19 != null) {
LocalCache.consume(nip19)
val route =
when (nip19) {
is NPub -> {
Route.Profile(nip19.hex)
}
is NProfile -> {
Route.Profile(nip19.hex)
}
is NNote -> {
Route.Note(nip19.hex)
}
is NEvent -> {
val note = LocalCache.getOrCreateNote(nip19.hex)
// Only fall back to the pointer's own kind while the body is missing: once the
// event is cached it may belong somewhere the kind alone can't name (a channel,
// a chatroom), and routeFor knows that.
if (note.event == null) {
routeForPointer(nip19.kind, nip19.hex) ?: Route.EventRedirect(nip19.hex)
} else {
routeFor(note = note, loggedIn = account) ?: Route.EventRedirect(nip19.hex)
}
}
is NAddress -> {
relayGroupDirectRoute(nip19, parsedNip19.additionalChars)
?: routeFor(
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
loggedIn = account,
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
}
is NEmbed -> {
val noteEvent = nip19.event
if (noteEvent is AddressableEvent) {
routeFor(
note = LocalCache.getOrCreateAddressableNote(noteEvent.address()),
loggedIn = account,
) ?: Route.EventRedirect(noteEvent.addressTag())
} else {
routeFor(
note = LocalCache.getOrCreateNote(nip19.event.id),
loggedIn = account,
) ?: Route.EventRedirect(nip19.event.id)
}
}
else -> {
null
}
}
if (route != null) {
return route
}
}
if (isMarmotGroupRoute(uri)) {
// marmot:<groupHex>?account=<npub>
val groupHex =
uri
.removePrefix("marmot:")
.substringBefore("?")
.substringBefore("&")
if (groupHex.matches(MARMOT_HEX)) {
return Route.MarmotGroupChat(groupHex)
}
}
if (isWalletConnectRoute(uri)) {
try {
val url = UriParser(uri)
val nip47Uri = url.getQueryParameter("value")?.firstOrNull()
if (nip47Uri != null) {
Nip47WalletConnect.parse(nip47Uri)
return Route.WalletAddNwc(nip47Uri)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
try {
Nip47WalletConnect.parse(uri)
return Route.WalletAddNwc(uri)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
return null
}
/**
* Direct route for an `naddr` whose event hasn't arrived in [LocalCache] yet. When a notification
* is tapped (or a `nostr:naddr…` deep link arrives) for a calendar appointment we know is kind
* 31922/31923, route straight to the dedicated detail screen instead of bouncing through
* [Route.EventRedirect]. The detail screen issues its own per-event subscription, so the user
* sees the calendar-specific loading placeholder while the event arrives, not the generic
* redirect screen.
*/
private fun calendarDirectRoute(nip19: NAddress): Route? =
when (nip19.kind) {
CalendarTimeSlotEvent.KIND,
CalendarDateSlotEvent.KIND,
-> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag)
else -> null
}
/**
* Direct route for a NIP-29 group `naddr` (kind 39000) shared as a link. The group
* only exists on its host relay, so the naddr's relay hint is mandatory — the group
* id alone can't be resolved. With a hint we open the group chat straight away
* (cold start included); without one there's nowhere to look, so fall through.
*/
private fun relayGroupDirectRoute(
nip19: NAddress,
additionalChars: String?,
): Route? {
if (nip19.kind != GroupMetadataEvent.KIND) return null
val relay = nip19.relay.firstOrNull() ?: return null
// The spec allows an invite code appended as `naddr1…?invite=<code>`; it arrives
// in the trailing chars after the bech32 body. Carry it so the group auto-joins.
val inviteCode = GroupNAddrInvite.parse(additionalChars)
return Route.RelayGroup(nip19.dTag, relay.url, inviteCode = inviteCode)
}
/**
* Direct route for a NIP-29 group invite link in the de-facto `<relay>'<groupId>[?code=<code>]`
* form shared by Wisp and 0xchat (e.g. `wss://groups.0xchat.com'abc123`). The link carries its
* own host relay, so we can open the group straight away. Returns null for anything that isn't
* this exact shape, so it's safe to try before the nostr-entity parser.
*/
private fun relayGroupInviteRoute(uri: String): Route? {
val link = GroupInviteLink.parse(uri.removePrefix(NOSTR_URI_PREFIX)) ?: return null
return Route.RelayGroup(link.groupId, link.relayUrl.url, inviteCode = link.code)
}
/**
* A shared Concord invite URL (`…/invite/<naddr>#<fragment>`). Cheap substring gates
* keep the parse off the hot path; the whole URL (fragment included) is carried into
* the route so the redeem flow still has the unlock token.
*/
private fun concordInviteRoute(uri: String): Route? =
if (uri.contains("/invite/") && uri.contains('#') && ConcordActions.parseInviteLink(uri) != null) {
Route.ConcordInvite(uri)
} else {
null
}
/**
* A Buzz workspace invite (`https://<host>/invite/<token>`) — a plain `/invite/` https URL with
* no fragment, so disjoint from the Concord shape above. Opens the in-app join flow
* ([Route.BuzzInvite]) instead of the external browser so the claim signs with the user's key.
*/
private fun buzzInviteRoute(uri: String): Route? =
if (uri.contains("/invite/") && BuzzInviteLink.parse(uri) != null) {
Route.BuzzInvite(uri)
} else {
null
}
@@ -1337,34 +1337,3 @@ fun URI.findParameterValue(parameterName: String): String? =
Pair(name, value)
}?.firstOrNull { it.first == parameterName }
?.second
/**
* The value of [parameterName] in this URI's query string, without going through
* [java.net.URI].
*
* Needed because `java.net.URI` only exposes `rawQuery` for *hierarchical* URIs. A URI
* with a scheme and no `//` is **opaque** — everything after the colon is one
* scheme-specific part — so `URI("marmot:<hex>?account=npub1…").rawQuery` is null. That
* is the shape [com.vitorpamplona.amethyst.service.notifications.NotificationRoutes.marmotUri]
* produces, so every Marmot group notification silently lost its `?account=` and opened
* the group under whichever account happened to be current instead of switching first.
*
* Splitting on the first `?` gets the same answer for both shapes, and returns null for a
* bare `nevent1…` with no query at all.
*
* [com.vitorpamplona.quartz.utils.UriParser] reads an opaque query correctly too, and is the
* right tool when a URI is already known to be well-formed. It is not this one: it builds a
* [java.net.URI], which *throws* on anything that is not a legal URI. What arrives here comes
* from an exported, browsable scheme, so it can be any string at all, and every caller on the
* deep-link path treats an unreadable uri as "no route" rather than as a crash.
*/
fun String.findQueryParameterValue(parameterName: String): String? {
val query = substringAfter('?', "")
if (query.isEmpty()) return null
return query
.split('&')
.firstOrNull { it.substringBefore('=') == parameterName }
?.substringAfter('=', "")
?.ifEmpty { null }
}
@@ -0,0 +1,52 @@
/*
* 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.navigation
/**
* The value of [parameterName] in this URI's query string, without going through
* `java.net.URI`.
*
* Needed because `java.net.URI` only exposes `rawQuery` for *hierarchical* URIs. A URI
* with a scheme and no `//` is **opaque** — everything after the colon is one
* scheme-specific part — so `URI("marmot:<hex>?account=npub1…").rawQuery` is null. That
* is the shape `NotificationRoutes.marmotUri`
* produces, so every Marmot group notification silently lost its `?account=` and opened
* the group under whichever account happened to be current instead of switching first.
*
* Splitting on the first `?` gets the same answer for both shapes, and returns null for a
* bare `nevent1…` with no query at all.
*
* `UriParser` reads an opaque query correctly too, and is the
* right tool when a URI is already known to be well-formed. It is not this one: it builds a
* `java.net.URI`, which *throws* on anything that is not a legal URI. What arrives here comes
* from an exported, browsable scheme, so it can be any string at all, and every caller on the
* deep-link path treats an unreadable uri as "no route" rather than as a crash.
*/
fun String.findQueryParameterValue(parameterName: String): String? {
val query = substringAfter('?', "")
if (query.isEmpty()) return null
return query
.split('&')
.firstOrNull { it.substringBefore('=') == parameterName }
?.substringAfter('=', "")
?.ifEmpty { null }
}
@@ -43,7 +43,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
@@ -55,6 +54,8 @@ import com.vitorpamplona.amethyst.commons.ui.insets.keyboardAsState
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalScreenLayout
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.LocalTabReselectCoordinator
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.platform.rememberNappletIconModel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.HorzPadding
@@ -64,7 +65,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size27Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.onSurface65
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
/** Content height of the [AppBottomBar] (the 50.dp Column inside [RenderBottomMenu]),
* exclusive of the system navigation-bar inset. Used by FAB callers that want to
@@ -114,7 +114,7 @@ fun AppBottomBar(
// Favorite entries in the unified list resolve to a live favorite for their icon/label and to an
// embedded-tab route. Both kinds embed in-process (WebApp → browser surface, NostrApp → napplet
// surface), so such a tab swaps in place rather than launching an activity from the bottom row.
val favorites by Amethyst.instance.favoriteApps.favorites
val favorites by LocalAppServices.current.favoriteApps.favorites
.collectAsStateWithLifecycle()
val isKeyboardState by keyboardAsState()
@@ -134,10 +134,11 @@ internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? =
when (fav) {
is FavoriteApp.WebApp -> {
// Captured favicons, keyed so the icon appears once the site's capture lands.
val iconKeys by Amethyst.instance.browserIcons.keys
val browserIcons = LocalAppServices.current.browserIcons
val iconKeys by browserIcons.keys
.collectAsStateWithLifecycle()
remember(fav, iconKeys) {
OmniboxInput.hostOf(fav.url)?.let(Amethyst.instance.browserIcons::iconModelFor)
OmniboxInput.hostOf(fav.url)?.let(browserIcons::iconModelFor)
}
}
@@ -44,10 +44,10 @@ import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.Channel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.platform.rememberConcordImageModel
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -40,10 +40,10 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.lack_location_permissions
import com.vitorpamplona.amethyst.commons.resources.loading_location
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
/** The "around me" feed's subtitle: asks for location, then shows the city it resolved to. */
@OptIn(ExperimentalPermissionsApi::class)
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.AddZapraise
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapToButton
import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.DisplayExternalId
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.commons.ui.platform.GeohashLocationPickerDialog
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -118,7 +119,6 @@ import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoad
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceFlap
import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSheet
@@ -61,10 +61,10 @@ import com.vitorpamplona.amethyst.commons.resources.share_as_qr_mode_web
import com.vitorpamplona.amethyst.commons.ui.components.LoadNote
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.KeepScreenBrightAndAwake
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.KeepScreenBrightAndAwake
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_QUIET_ZONE_MODULES in QrCodeDrawer.kt) is a
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
@@ -24,11 +24,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.favoriteCoordinateOf
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.StaticWebsiteCard
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
@@ -68,7 +68,7 @@ fun RenderRootNappletEvent(
} else {
null
},
headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: "Napplet", event.icon()) },
headerActions = { FavoriteToggleButton(favoriteCoordinateOf(event), event.title() ?: "Napplet", event.icon()) },
)
}
@@ -98,7 +98,7 @@ fun RenderNamedNappletEvent(
} else {
null
},
headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: event.identifier(), event.icon()) },
headerActions = { FavoriteToggleButton(favoriteCoordinateOf(event), event.title() ?: event.identifier(), event.icon()) },
)
}
@@ -139,7 +139,7 @@ fun RenderRootSiteEvent(
} else {
null
},
headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: "nsite", event.icon()) },
headerActions = { FavoriteToggleButton(favoriteCoordinateOf(event), event.title() ?: "nsite", event.icon()) },
)
}
@@ -180,7 +180,7 @@ fun RenderNamedSiteEvent(
} else {
null
},
headerActions = { FavoriteToggleButton(FavoriteAppLauncher.coordinateOf(event), event.title() ?: event.identifier(), event.icon()) },
headerActions = { FavoriteToggleButton(favoriteCoordinateOf(event), event.title() ?: event.identifier(), event.icon()) },
)
}
@@ -20,18 +20,31 @@
*/
package com.vitorpamplona.amethyst.ui.platform
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppLauncher
import com.vitorpamplona.amethyst.commons.ui.platform.AppPlatform
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.ScanOutcome
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.ui.navigation.topbars.AndroidAroundMeLocationLabel
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel as AppRememberManifestIconModel
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel as AppRememberNappletIconModel
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel as AppRememberWebAppIconModel
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar as AppBottomBarImpl
import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerContent as AppGeohashLocationPickerContent
import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog as AppGeohashLocationPickerDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel as AppRememberConcordImageModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.QrCodeScannerDialog as AppQrCodeScannerDialog
/** Android's [AppPlatform]: the app's own bottom bar and location label. */
/** Android's [AppPlatform]: the app's own shell pieces, camera scanner, app launcher and icon caches. */
object AndroidAppPlatform : AppPlatform {
@Composable
override fun AppBottomBar(
@@ -58,4 +71,48 @@ object AndroidAppPlatform : AppPlatform {
onConfirm: (String) -> Unit,
modifier: Modifier,
) = AppGeohashLocationPickerContent(initialGeohash, confirmLabel, onConfirm, modifier)
@Composable
override fun QrCodeScannerDialog(
onDismiss: () -> Unit,
onScan: (String) -> ScanOutcome,
) = AppQrCodeScannerDialog(onDismiss, onScan)
@Composable
override fun rememberAppLauncher(): AppLauncher {
val context = LocalContext.current
return remember(context) { AndroidAppLauncher(context) }
}
@Composable
override fun rememberConcordImageModel(
pointer: ImagePointer?,
accountViewModel: AccountViewModel,
): String? = AppRememberConcordImageModel(pointer, accountViewModel)
@Composable
override fun rememberNappletIconModel(coordinate: String): String? = AppRememberNappletIconModel(coordinate)
@Composable
override fun rememberManifestIconModel(
author: String,
identifier: String,
): String? = AppRememberManifestIconModel(author, identifier)
@Composable
override fun rememberWebAppIconModel(url: String): String? = AppRememberWebAppIconModel(url)
}
private class AndroidAppLauncher(
private val context: Context,
) : AppLauncher {
override fun launch(
app: FavoriteApp,
stillLoading: String,
) = FavoriteAppLauncher.launch(context, app, stillLoading)
override fun launchUrl(
url: String,
preferTor: Boolean,
) = FavoriteAppLauncher.launchUrl(context, url, preferTor)
}
@@ -0,0 +1,49 @@
/*
* 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.platform
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.service.AppServices
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
/**
* [AppServices] over the main process's app modules. Every member is a getter, so installing
* this object costs nothing and is safe in the `:napplet` process, where `Amethyst.instance` is
* never built, as long as nothing there reads it.
*/
object AndroidAppServices : AppServices {
override val favoriteApps: FavoriteAppsRegistry get() = Amethyst.instance.favoriteApps
override val browserHistory: BrowserHistoryRegistry get() = Amethyst.instance.browserHistory
override val browserIcons: BrowserIconRegistry get() = Amethyst.instance.browserIcons
override val nappletPermissionLedger: NappletPermissionLedger get() = Amethyst.instance.nappletPermissionLedger
override val signerPermissionStore: NostrSignerPermissionStore get() = Amethyst.instance.signerPermissionStore
override val torSettings: TorSettingsFlow get() = Amethyst.instance.torPrefs.value
}
@@ -100,24 +100,24 @@ open class UserFeedViewModel(
}
init {
Log.d("Init") { "${this.javaClass.simpleName}" }
Log.d("Init") { "${this::class.simpleName}" }
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
Log.d("Rendering Metrics") { "Update feeds: ${this@UserFeedViewModel.javaClass.simpleName} with ${newNotes.size}" }
Log.d("Rendering Metrics") { "Update feeds: ${this@UserFeedViewModel::class.simpleName} with ${newNotes.size}" }
invalidateData()
}
}
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.deletedEventBundles.collect { newNotes ->
Log.d("Rendering Metrics") { "Delete from feeds: ${this@UserFeedViewModel.javaClass.simpleName} with ${newNotes.size}" }
Log.d("Rendering Metrics") { "Delete from feeds: ${this@UserFeedViewModel::class.simpleName} with ${newNotes.size}" }
invalidateData()
}
}
}
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
bundler.cancel()
}
}
@@ -29,16 +29,21 @@ import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlTransport
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure
import com.vitorpamplona.amethyst.commons.tor.MoneyOpRelayRouting
import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModelHost
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismissNotificationForEvent
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthSnapshot
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import kotlinx.collections.immutable.PersistentMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
@@ -60,6 +65,11 @@ class AndroidAccountViewModelHost(
override val moneyOpRelays: MoneyOpRelayRouting get() = modules.torEvaluatorFlow
override val torRelayEvaluation: StateFlow<TorRelayEvaluation> get() = modules.torEvaluatorFlow.flow
override val relayAuthState: StateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>
get() = modules.authCoordinator.receiver.authStateFlow
override val savedAccounts: Flow<Set<HexKey>> =
LocalPreferences
.accountsFlow()
@@ -142,13 +142,13 @@ import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.diff.ValueChange
@@ -117,12 +117,12 @@ import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
@@ -114,7 +114,7 @@ import com.vitorpamplona.amethyst.commons.resources.backup_type_trust_providers
import com.vitorpamplona.amethyst.commons.resources.backup_type_trust_providers_explainer
import com.vitorpamplona.amethyst.commons.resources.backup_type_trusted_relays
import com.vitorpamplona.amethyst.commons.resources.backup_type_trusted_relays_explainer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
@@ -63,7 +63,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -74,7 +73,6 @@ 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.commons.browser.BrowserHistoryEntry
import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
@@ -82,6 +80,7 @@ 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.favorites.favoriteCoordinateOf
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
@@ -89,6 +88,7 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.browser_address_hint
import com.vitorpamplona.amethyst.commons.resources.browser_clear
import com.vitorpamplona.amethyst.commons.resources.browser_discover_napplets
import com.vitorpamplona.amethyst.commons.resources.browser_discover_nsites
@@ -104,21 +104,21 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.platform.rememberAppLauncher
import com.vitorpamplona.amethyst.commons.ui.platform.rememberNappletIconModel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.favoriteAppItems
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
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 org.jetbrains.compose.resources.StringResource
import com.vitorpamplona.amethyst.commons.R as CommonsR
/** How many of the most recent history entries the idle browser home surfaces under "Recent". */
private const val RECENTS_LIMIT = 12
@@ -150,13 +150,14 @@ private fun BrowserLauncher(
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
val appServices = LocalAppServices.current
val appLauncher = rememberAppLauncher()
val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading)
val apps by Amethyst.instance.favoriteApps.favorites
val apps by appServices.favoriteApps.favorites
.collectAsStateWithLifecycle()
val history by Amethyst.instance.browserHistory.history
val history by appServices.browserHistory.history
.collectAsStateWithLifecycle()
val iconKeys by Amethyst.instance.browserIcons.keys
val iconKeys by appServices.browserIcons.keys
.collectAsStateWithLifecycle()
// Fetch favorited nsite/napplet manifests up front so tapping one launches immediately instead of
@@ -227,7 +228,7 @@ private fun BrowserLauncher(
fun open(text: String) {
val target = OmniboxInput.resolve(text) ?: return
FavoriteAppLauncher.launchUrl(context, target.url, target.forceTor)
appLauncher.launchUrl(target.url, target.forceTor)
}
// Pin/unpin a plain web URL by its favorite id. Shared by the suggestion list and the Recent rows.
@@ -236,10 +237,10 @@ private fun BrowserLauncher(
label: String,
) {
val id = "url:$url"
if (Amethyst.instance.favoriteApps.isFavorite(id)) {
Amethyst.instance.favoriteApps.remove(id)
if (appServices.favoriteApps.isFavorite(id)) {
appServices.favoriteApps.remove(id)
} else {
Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis()))
appServices.favoriteApps.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis()))
}
}
@@ -291,7 +292,7 @@ private fun BrowserLauncher(
historyUrls = historyUrls,
onOpen = { open(it.url) },
onToggleFavorite = { toggleFavorite(it.url, it.label) },
onRemoveFromHistory = { Amethyst.instance.browserHistory.remove(it) },
onRemoveFromHistory = { appServices.browserHistory.remove(it) },
modifier = contentModifier,
)
else -> {
@@ -306,12 +307,12 @@ private fun BrowserLauncher(
suggested = suggested,
nsites = followedNsites,
napplets = followedNapplets,
onOpenApp = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) },
onRemoveApp = { Amethyst.instance.favoriteApps.remove(it.id) },
onAddApp = { Amethyst.instance.favoriteApps.add(it) },
onOpenApp = { appLauncher.launch(it, appStillLoadingStr) },
onRemoveApp = { appServices.favoriteApps.remove(it.id) },
onAddApp = { appServices.favoriteApps.add(it) },
onOpenUrl = { open(it) },
onToggleRecentFavorite = { entry -> toggleFavorite(entry.url, entry.title.ifBlank { entry.host }) },
onRemoveRecent = { Amethyst.instance.browserHistory.remove(it) },
onRemoveRecent = { appServices.browserHistory.remove(it) },
modifier = contentModifier,
)
}
@@ -348,7 +349,7 @@ private fun OmniBar(
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
singleLine = true,
placeholder = { Text(stringRes(CommonsR.string.browser_address_hint)) },
placeholder = { Text(stringRes(Res.string.browser_address_hint)) },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
@@ -572,7 +573,8 @@ private fun SuggestedRow(
onClick: () -> Unit,
onAddFavorite: () -> Unit,
) {
val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(Amethyst.instance.browserIcons::iconModelFor) }
val appServices = LocalAppServices.current
val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(appServices.browserIcons::iconModelFor) }
Row(
modifier =
Modifier
@@ -644,7 +646,7 @@ private fun List<Note>.toDiscoverApps(
/** 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 coordinate = favoriteCoordinateOf(event)
val label: String
val description: String?
when (event) {
@@ -803,7 +805,8 @@ private fun SiteIcon(
iconKeys: Set<String>,
modifier: Modifier = Modifier,
) {
val model = remember(host, iconKeys) { Amethyst.instance.browserIcons.iconModelFor(host) }
val appServices = LocalAppServices.current
val model = remember(host, iconKeys) { appServices.browserIcons.iconModelFor(host) }
val symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.Public
val tint = MaterialTheme.colorScheme.onSurfaceVariant
if (model == null) {
@@ -47,7 +47,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -69,9 +68,10 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_invite_title
import com.vitorpamplona.amethyst.commons.resources.buzz_invite_workspace
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.platform.AppLauncher
import com.vitorpamplona.amethyst.commons.ui.platform.rememberAppLauncher
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -85,7 +85,7 @@ import kotlinx.coroutines.launch
* a flow the Buzz web app already implements and drives through `window.nostr`. So rather than
* re-implement the legally-sensitive age/privacy consent natively, this confirms the workspace,
* marks its relay as a Buzz dialect, and hands the URL to the in-app `window.nostr` browser
* ([FavoriteAppLauncher.launchUrl] → the sandboxed WebView), where the SPA signs the claim with
* ([AppLauncher.launchUrl] → the sandboxed WebView), where the SPA signs the claim with
* the user's key. The user returns here (or to the app) once enrolled.
*/
@Composable
@@ -95,7 +95,7 @@ fun BuzzInviteScreen(
nav: INav,
) {
val invite = remember(link) { BuzzInviteLink.parse(link) }
val context = LocalContext.current
val appLauncher = rememberAppLauncher()
val scope = rememberCoroutineScope()
var launched by remember { mutableStateOf(false) }
@@ -201,7 +201,7 @@ fun BuzzInviteScreen(
accountViewModel.account.buzzWorkspaces.join(relay)
scope.launch { accountViewModel.account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
}
FavoriteAppLauncher.launchUrl(context, link)
appLauncher.launchUrl(link)
launched = true
},
enabled = !expired,
@@ -55,7 +55,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -88,7 +87,7 @@ fun RelayStatusBar(
.connectedRelaysFlow()
.collectAsStateWithLifecycle()
val authState =
Amethyst.instance.authCoordinator.receiver.authStateFlow
accountViewModel.host.relayAuthState
.collectAsStateWithLifecycle()
val buzzState = BuzzRelayDialect.flow.collectAsStateWithLifecycle()
val info by loadRelayInfo(relay)
@@ -96,13 +96,13 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.note.types.CalendarRsvpRow
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size30dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
@@ -132,12 +132,12 @@ import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowU
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SectionCollapse
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SectionExpand
import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest
@@ -73,9 +73,9 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SearchTopBarAction
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatActingIdentities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatDisplayNameResolver
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatReactOverride
@@ -69,10 +69,10 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -67,8 +67,8 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -110,9 +110,9 @@ import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.platform.rememberConcordImageModel
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
@@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.commons.ui.platform.rememberConcordImageModel
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.MediumRelayIconModifier
@@ -41,13 +41,13 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.ui.components.LoadNote
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.platform.ZapReaction
import com.vitorpamplona.amethyst.commons.ui.note.types.GoalProgressBar
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent
/**
@@ -108,13 +108,13 @@ import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.platform.GeohashLocationPickerDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
@@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.commons.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.commons.ui.platform.rememberConcordImageModel
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
@@ -154,7 +155,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotOth
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordLeaveDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag
@@ -59,6 +59,8 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.DisplayAuthorBanner
import com.vitorpamplona.amethyst.commons.ui.note.Gallery
import com.vitorpamplona.amethyst.commons.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.commons.ui.note.platform.LikeReaction
import com.vitorpamplona.amethyst.commons.ui.note.platform.ZapReaction
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
@@ -67,8 +69,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.util.equalImmutableLists
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.DisplayAuthorBanner
import com.vitorpamplona.amethyst.commons.ui.note.Gallery
import com.vitorpamplona.amethyst.commons.ui.note.platform.LikeReaction
import com.vitorpamplona.amethyst.commons.ui.note.platform.ZapReaction
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
@@ -68,8 +70,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.util.equalImmutableLists
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import kotlinx.collections.immutable.ImmutableList
@@ -46,6 +46,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.commons.ui.note.platform.LikeReaction
import com.vitorpamplona.amethyst.commons.ui.note.platform.ZapReaction
import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.commons.ui.theme.SimpleImageBorder
@@ -54,8 +56,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.nip05
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteAlgoFeedToggle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
@@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nip99Classifieds.tags.PriceTag
import com.vitorpamplona.quartz.utils.parseBigDecimalOrNull
@Immutable
data class ClassifiedsThumb(
@@ -141,7 +142,7 @@ fun InnerRenderClassifiedsThumb(
card.price?.let {
val priceTag =
remember(card) {
val newAmount = it.amount.toBigDecimalOrNull()?.let { showAmountInteger(it) } ?: it.amount
val newAmount = parseBigDecimalOrNull(it.amount)?.let { showAmountInteger(it) } ?: it.amount
if (it.frequency != null && it.currency != null) {
"$newAmount ${it.currency}/${it.frequency}"
@@ -54,12 +54,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
@@ -73,11 +71,12 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_apps
import com.vitorpamplona.amethyst.commons.resources.favorite_apps_empty
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.platform.rememberAppLauncher
import com.vitorpamplona.amethyst.commons.ui.platform.rememberNappletIconModel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
/**
* The Favorite Apps grid: the user's pinned web clients / nsites / napplets as big launch buttons.
@@ -91,9 +90,10 @@ fun FavoriteAppsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val appServices = LocalAppServices.current
val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading)
val context = LocalContext.current
val apps by Amethyst.instance.favoriteApps.favorites
val appLauncher = rememberAppLauncher()
val apps by appServices.favoriteApps.favorites
.collectAsStateWithLifecycle()
// Fetch favorited nsite/napplet manifests up front so a tap launches immediately instead of showing
@@ -125,8 +125,8 @@ fun FavoriteAppsScreen(
} else {
FavoriteAppsGrid(
apps = apps,
onOpen = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) },
onRemove = { Amethyst.instance.favoriteApps.remove(it.id) },
onOpen = { appLauncher.launch(it, appStillLoadingStr) },
onRemove = { appServices.favoriteApps.remove(it.id) },
modifier =
Modifier
.fillMaxSize()
@@ -184,18 +184,19 @@ internal fun FavoriteAppCell(
onOpen: () -> Unit,
onRemove: () -> Unit,
) {
val appServices = LocalAppServices.current
var menuOpen by remember { mutableStateOf(false) }
// For a plain web favorite, prefer the favicon captured when its site was opened; an nsite/napplet uses
// the verified icon blob bundled in its own content. Observing the key set recomputes the model as a
// captured favicon arrives.
val iconKeys by Amethyst.instance.browserIcons.keys
val iconKeys by appServices.browserIcons.keys
.collectAsStateWithLifecycle()
val faviconModel =
when (app) {
is FavoriteApp.WebApp ->
remember(app, iconKeys) {
OmniboxInput.hostOf(app.url)?.let(Amethyst.instance.browserIcons::iconModelFor)
OmniboxInput.hostOf(app.url)?.let(appServices.browserIcons::iconModelFor)
}
is FavoriteApp.NostrApp -> rememberNappletIconModel(app.coordinate)
}
@@ -69,6 +69,8 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TitleIconModifier
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarSize
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.ui.note.platform.LikeReaction
import com.vitorpamplona.amethyst.commons.ui.note.platform.ZapReaction
import com.vitorpamplona.amethyst.commons.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHorzSpacer
@@ -76,9 +78,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.SpacedBy2dp
import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ReplyReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackFeedConversationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackFeedNewThreadFeedViewModel
@@ -106,10 +106,10 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.geocacheEmoji
import com.vitorpamplona.amethyst.commons.ui.note.geocacheLabelRes
import com.vitorpamplona.amethyst.commons.ui.platform.GeohashLocationPickerDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheGeohash
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
@@ -60,9 +60,9 @@ import com.vitorpamplona.amethyst.commons.resources.geocache_owner_no_finders
import com.vitorpamplona.amethyst.commons.resources.geocache_owner_qr_warning
import com.vitorpamplona.amethyst.commons.resources.geocache_owner_show_qr
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nipCCGeocaching.firstToFind.FirstToFindResolver
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
@@ -45,10 +45,10 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarP
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SearchTopBarAction
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.dal.GeoHashFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -70,11 +70,11 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip34Git.git.GitCommit
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
@@ -34,9 +34,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
@@ -136,6 +136,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -143,7 +144,6 @@ import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.note.authenticate
import com.vitorpamplona.amethyst.ui.note.rememberAuthPromptLabels
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
import kotlinx.coroutines.CoroutineScope
@@ -114,9 +114,9 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_reconnecting
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_remote_app
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.platform.rememberManifestIconModel
import com.vitorpamplona.amethyst.commons.ui.platform.rememberWebAppIconModel
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel
import com.vitorpamplona.amethyst.napplet.NappletBrokerService
import com.vitorpamplona.amethyst.napplet.WebSitePermissionRegistry
import com.vitorpamplona.amethyst.napplet.counterpartyLabel
@@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
@@ -69,13 +68,15 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_permissions_title
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_full_trust
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_paranoid
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable
import com.vitorpamplona.amethyst.commons.resources.napplet_untitled
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.platform.rememberManifestIconModel
import com.vitorpamplona.amethyst.commons.ui.platform.rememberWebAppIconModel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.ConnectedAppsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
@@ -86,7 +87,6 @@ import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.vitorpamplona.amethyst.commons.R as CommonsR
/** Author placeholder used by the browser permission path — not a real pubkey. */
private const val BROWSER_AUTHOR = "browser"
@@ -101,8 +101,9 @@ fun ConnectedAppsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val capabilityLedger = Amethyst.instance.nappletPermissionLedger
val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
val appServices = LocalAppServices.current
val capabilityLedger = appServices.nappletPermissionLedger
val signerLedger = remember { NostrSignerPermissionLedger(appServices.signerPermissionStore) }
var items by remember { mutableStateOf<List<ConnectedAppEntry>?>(null) }
var nappletAuthors by remember { mutableStateOf<Set<HexKey>>(emptySet()) }
@@ -156,7 +157,7 @@ fun ConnectedAppsScreen(
}
else -> {
val untitled = stringRes(CommonsR.string.napplet_untitled)
val untitled = stringRes(Res.string.napplet_untitled)
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
@@ -91,6 +91,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.LongPressToQuickAction
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.commons.ui.note.platform.LikeReaction
import com.vitorpamplona.amethyst.commons.ui.note.platform.ZapReaction
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
@@ -105,8 +107,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.LoadParticipants
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -49,9 +49,9 @@ import com.vitorpamplona.amethyst.commons.ui.components.ContentWarningGate
import com.vitorpamplona.amethyst.commons.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.commons.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
@@ -44,13 +44,13 @@ import com.vitorpamplona.amethyst.commons.resources.podcast_season
import com.vitorpamplona.amethyst.commons.resources.podcast_season_episode
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoWith
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.podcast_episode_count
import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.note.types.PodcastCoverCard
import com.vitorpamplona.amethyst.commons.ui.note.types.PodcastPeople
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
@@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.podcasts.PodcastShow
@@ -77,11 +77,11 @@ import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDial
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SendingTopBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.CoverImagePicker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.persistentListOf
import org.jetbrains.compose.resources.StringResource
@@ -54,7 +54,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -103,6 +102,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SegmentedChoiceTile
@@ -111,7 +112,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsControlRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import org.jetbrains.compose.resources.StringResource
@@ -119,7 +119,7 @@ import org.jetbrains.compose.resources.StringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacyOptionsScreen(nav: INav) {
PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav)
PrivacyOptionsScreen(LocalAppServices.current.torSettings, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -66,9 +66,9 @@ import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.quartz.nipA3PaymentTargets.PaymentTarget
import kotlinx.coroutines.launch
@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.qrcode.ScannedPayload
import com.vitorpamplona.amethyst.commons.qrcode.classifyScannedPayload
import com.vitorpamplona.amethyst.commons.ui.platform.QrCodeScannerDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.ScanOutcome
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.QrCodeScannerDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ScanOutcome
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
@@ -64,6 +64,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.ObserveAndDisplayNIP05
import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.KeepScreenBrightAndAwake
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
@@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.commons.resources.qr_scanner_grant_camera
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_no_code_in_image
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_open_settings
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_unavailable
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.ScanOutcome
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.call.openAppSettings
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
@@ -114,15 +115,6 @@ import kotlinx.coroutines.launch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/** What the caller did with a decoded payload — and therefore what the scanner does next. */
enum class ScanOutcome {
/** The caller acted on it. Close the scanner. */
Handled,
/** Decoded fine, but this screen has nothing to do with it. Explain, and keep scanning. */
NotSupported,
}
/**
* Full-screen QR scanner.
*
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -41,6 +41,6 @@ fun relaySetupInfoBuilder(
): BasicRelaySetupInfo =
BasicRelaySetupInfo(
relay = normalized,
relayStat = Amethyst.instance.relayStats.get(normalized),
relayStat = LocalCache.appHost.relayStats?.get(normalized) ?: RelayStat(),
forcesTor = forcesTor,
)
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.relays.ui.CountFilter
@@ -131,7 +130,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
relaySetupInfoBuilder(
normalized = it,
forcesTor =
Amethyst.instance.torEvaluatorFlow.flow.value
accountViewModel.host.torRelayEvaluation.value
.useTor(it),
)
}.distinctBy { it.relay }
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
@@ -36,7 +35,7 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() {
return relayList
.map {
val reqs = Amethyst.instance.client.activeRequests(it)
val reqs = account.client.activeRequests(it)
val users = mutableSetOf<HexKey>()
reqs.forEach { _, filters ->
@@ -55,9 +54,9 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() {
BasicRelaySetupInfo(
relay = it,
relayStat = Amethyst.instance.relayStats.get(it),
relayStat = accountViewModel.host.relayStats.get(it),
forcesTor =
Amethyst.instance.torEvaluatorFlow.flow.value
accountViewModel.host.torRelayEvaluation.value
.useTor(it),
users = users.mapNotNull { hex -> LocalCache.checkGetOrCreateUser(hex) },
)
@@ -92,18 +92,18 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.platform.rememberConcordImageModel
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@@ -71,10 +71,10 @@ import com.vitorpamplona.amethyst.commons.ui.settings.SettingsCategory
import com.vitorpamplona.amethyst.commons.ui.settings.SettingsEntry
import com.vitorpamplona.amethyst.commons.ui.settings.SettingsIcon
import com.vitorpamplona.amethyst.commons.ui.settings.filterSettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
@@ -30,22 +30,17 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -61,7 +56,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
@@ -122,7 +116,6 @@ import com.vitorpamplona.amethyst.commons.resources.ui_feature_set_type_simplifi
import com.vitorpamplona.amethyst.commons.resources.ui_style
import com.vitorpamplona.amethyst.commons.resources.ui_style_description
import com.vitorpamplona.amethyst.commons.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -134,7 +127,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.ui.theme.previewColor
import com.vitorpamplona.amethyst.commons.ui.theme.toFontFamily
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
@@ -219,53 +211,6 @@ fun SettingsScreen(
}
}
/**
* A [SettingsBlockTile] whose control is a full-width [SingleChoiceSegmentedButtonRow].
* This is the in-screen replacement for the old dropdown ([TextSpinner]) rows: every
* option is visible and one tap away. Best for 2–4 mutually-exclusive options.
*
* [optionTextStyle] lets each option render its own label preview — e.g. the font tiles
* draw each label in the very typeface / size it selects, so the row demonstrates the
* choices instead of only naming them.
*/
@Composable
internal fun <T> SegmentedChoiceTile(
icon: MaterialSymbol,
title: StringResource,
description: StringResource,
options: List<T>,
labelRes: (T) -> StringResource,
selected: T,
onSelect: (T) -> Unit,
optionTextStyle: (@Composable (T) -> TextStyle)? = null,
) {
SettingsBlockTile(
icon = icon,
title = stringRes(title),
description = stringRes(description),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, option ->
SegmentedButton(
selected = option == selected,
onClick = { onSelect(option) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size),
// Drop the default check icon: the fill already signals selection, and the icon
// steals ~24dp that the label needs in a 3–4-up row.
icon = {},
) {
Text(
text = stringRes(labelRes(option)),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = optionTextStyle?.invoke(option) ?: LocalTextStyle.current,
)
}
}
}
}
}
/** A [SettingsControlRow] with a [Switch] backed by a [BooleanType] flow (ALWAYS ⇔ on). */
@Composable
private fun BooleanSwitchTile(
@@ -617,91 +562,3 @@ private fun AccentColorSwatch(
}
}
}
@Composable
fun SettingsRow(
name: StringResource,
description: StringResource,
selectedItems: ImmutableList<TitleExplainer>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
) {
SettingsRow(name, description) {
TextSpinner(
label = "",
placeholder = selectedItems[selectedIndex].title,
options = selectedItems,
onSelect = onSelect,
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
)
}
}
@Composable
fun SettingsRow(
name: StringResource,
description: StringResource,
modifier: Modifier = Modifier.fillMaxWidth(),
content: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Column(
modifier = Modifier.weight(2.0f),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = stringRes(name),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(description),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Column(
Modifier.weight(1f),
horizontalAlignment = Alignment.End,
) {
content()
}
}
}
@Composable
fun SettingsRow(
name: StringResource,
description: StringResource,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Column(
modifier = Modifier.weight(2.0f),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = stringRes(name),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(description),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -67,7 +67,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -91,6 +90,8 @@ import com.vitorpamplona.amethyst.commons.resources.bottom_bar_settings_reorder_
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -102,7 +103,6 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCategory
import com.vitorpamplona.amethyst.ui.navigation.bottombars.rememberFavoriteIconModel
import com.vitorpamplona.amethyst.ui.navigation.bottombars.rememberGroupEntryDisplay
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import kotlinx.coroutines.flow.MutableStateFlow
@@ -498,7 +498,7 @@ private fun PickerChildren(
) {
when (item) {
NavBarItem.BROWSER -> {
val favorites by Amethyst.instance.favoriteApps.favorites
val favorites by LocalAppServices.current.favoriteApps.favorites
.collectAsStateWithLifecycle()
if (favorites.isEmpty()) {
EmptyChildHint(Res.string.bottom_bar_settings_no_favorites)
@@ -818,7 +818,7 @@ private fun rememberPinnedVisual(
PinnedVisual.Glyph(def?.icon ?: MaterialSymbols.Apps, def?.let { stringRes(it.labelRes) } ?: "")
}
is BottomBarEntry.Favorite -> {
val favorites by Amethyst.instance.favoriteApps.favorites
val favorites by LocalAppServices.current.favoriteApps.favorites
.collectAsStateWithLifecycle()
val app = favorites.firstOrNull { it.id == entry.favoriteId }
if (app != null) PinnedVisual.Favorite(app) else PinnedVisual.Glyph(MaterialSymbols.Public, "")
@@ -0,0 +1,183 @@
/*
* 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.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import org.jetbrains.compose.resources.StringResource
/**
* A [SettingsBlockTile] whose control is a full-width [SingleChoiceSegmentedButtonRow].
* This is the in-screen replacement for the old dropdown ([TextSpinner]) rows: every
* option is visible and one tap away. Best for 2–4 mutually-exclusive options.
*
* [optionTextStyle] lets each option render its own label preview — e.g. the font tiles
* draw each label in the very typeface / size it selects, so the row demonstrates the
* choices instead of only naming them.
*/
@Composable
fun <T> SegmentedChoiceTile(
icon: MaterialSymbol,
title: StringResource,
description: StringResource,
options: List<T>,
labelRes: (T) -> StringResource,
selected: T,
onSelect: (T) -> Unit,
optionTextStyle: (@Composable (T) -> TextStyle)? = null,
) {
SettingsBlockTile(
icon = icon,
title = stringRes(title),
description = stringRes(description),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, option ->
SegmentedButton(
selected = option == selected,
onClick = { onSelect(option) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size),
// Drop the default check icon: the fill already signals selection, and the icon
// steals ~24dp that the label needs in a 3–4-up row.
icon = {},
) {
Text(
text = stringRes(labelRes(option)),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = optionTextStyle?.invoke(option) ?: LocalTextStyle.current,
)
}
}
}
}
}
@Composable
fun SettingsRow(
name: StringResource,
description: StringResource,
selectedItems: ImmutableList<TitleExplainer>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
) {
SettingsRow(name, description) {
TextSpinner(
label = "",
placeholder = selectedItems[selectedIndex].title,
options = selectedItems,
onSelect = onSelect,
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
)
}
}
@Composable
fun SettingsRow(
name: StringResource,
description: StringResource,
modifier: Modifier = Modifier.fillMaxWidth(),
content: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Column(
modifier = Modifier.weight(2.0f),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = stringRes(name),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(description),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Column(
Modifier.weight(1f),
horizontalAlignment = Alignment.End,
) {
content()
}
}
}
@Composable
fun SettingsRow(
name: StringResource,
description: StringResource,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Column(
modifier = Modifier.weight(2.0f),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = stringRes(name),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(description),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -82,10 +82,10 @@ import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.model.cordn.AndroidCordnBlobStore
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode
@@ -125,9 +125,9 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import kotlinx.coroutines.launch
@@ -51,9 +51,9 @@ import com.vitorpamplona.amethyst.commons.ui.components.ContentWarningGate
import com.vitorpamplona.amethyst.commons.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.commons.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip31Alts.alt
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.note.types.AppAuthorLine
import com.vitorpamplona.amethyst.commons.ui.note.types.AppIcon
import com.vitorpamplona.amethyst.commons.ui.note.types.AppLinksColumn
@@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedViewModel
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -144,6 +144,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.DisplayCommentScope
import com.vitorpamplona.amethyst.commons.ui.note.observeEdits
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.note.types.BadgeDisplay
import com.vitorpamplona.amethyst.commons.ui.note.types.DisplayBlockedRelayList
import com.vitorpamplona.amethyst.commons.ui.note.types.DisplayBroadcastRelayList
@@ -262,7 +263,6 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.thread.LevelFeedViewModel
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.types.AudioHeader
import com.vitorpamplona.amethyst.ui.note.types.AudioTrackHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
@@ -45,11 +45,11 @@ import com.vitorpamplona.amethyst.commons.ui.components.ContentWarningGate
import com.vitorpamplona.amethyst.commons.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.commons.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.note.types.FileMetadataAttachmentCard
import com.vitorpamplona.amethyst.commons.ui.note.types.toMediaContent
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent
@@ -126,6 +126,7 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.sats
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.commons.util.formatGrouped
import com.vitorpamplona.amethyst.commons.util.showAmount
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow
@@ -144,7 +145,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import java.text.NumberFormat
/** Shared with the profile Send Payment screen's on-chain rail. */
internal enum class FeeTier(
@@ -706,7 +706,7 @@ private fun AmountSection(
if (belowMinimum) {
Spacer(Modifier.height(4.dp))
Text(
text = stringRes(Res.string.onchain_send_min_warning, NumberFormat.getNumberInstance().format(MIN_ONCHAIN_ZAP_SATS)),
text = stringRes(Res.string.onchain_send_min_warning, formatGrouped(MIN_ONCHAIN_ZAP_SATS)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
@@ -806,7 +806,7 @@ private fun SendButton(
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.size(8.dp))
val sats = if (amountSats != null && amountSats > 0) NumberFormat.getNumberInstance().format(amountSats) else null
val sats = if (amountSats != null && amountSats > 0) formatGrouped(amountSats) else null
Text(
text =
when {
@@ -866,7 +866,7 @@ private fun SplitsRecipientSection(
if (share != null) {
val belowDust = share.sats < OnchainZapBuilder.DUST_THRESHOLD_SATS
Text(
text = "${NumberFormat.getNumberInstance().format(share.sats)} sats",
text = "${formatGrouped(share.sats)} sats",
style = MaterialTheme.typography.bodySmall,
color =
if (belowDust) {
@@ -971,12 +971,12 @@ private fun SuccessBody(result: OnchainZapSendResult.Success) {
ResultRow(stringRes(Res.string.onchain_send_result_transaction), result.txid)
ResultRow(
stringRes(Res.string.onchain_send_result_fee),
stringRes(Res.string.onchain_send_sats_amount, NumberFormat.getNumberInstance().format(result.feeSats)),
stringRes(Res.string.onchain_send_sats_amount, formatGrouped(result.feeSats)),
)
if (result.changeSats > 0) {
ResultRow(
stringRes(Res.string.onchain_send_result_change),
stringRes(Res.string.onchain_send_sats_amount, NumberFormat.getNumberInstance().format(result.changeSats)),
stringRes(Res.string.onchain_send_sats_amount, formatGrouped(result.changeSats)),
)
}
}
@@ -74,9 +74,9 @@ import com.vitorpamplona.amethyst.commons.resources.wallet_description
import com.vitorpamplona.amethyst.commons.resources.wallet_receive
import com.vitorpamplona.amethyst.commons.resources.wallet_sats
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import java.text.NumberFormat
@OptIn(ExperimentalMaterial3Api::class)
@@ -78,6 +78,8 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.StringResource
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
sealed class SendState {
data object Idle : SendState()
@@ -338,6 +340,7 @@ class WalletViewModel : ViewModel() {
}
/** Adds a CLINK debit pointer (`ndebit1…`) as a spend-only payment source. */
@OptIn(ExperimentalUuidApi::class)
fun addClinkDebitWallet(
name: String,
ndebit: String,
@@ -347,9 +350,7 @@ class WalletViewModel : ViewModel() {
val entry =
ClinkDebitWalletEntryNorm(
id =
java.util.UUID
.randomUUID()
.toString(),
Uuid.random().toString(),
name = name.ifBlank { "Debit" },
pointer = pointer,
)
@@ -384,6 +385,7 @@ class WalletViewModel : ViewModel() {
}
}
@OptIn(ExperimentalUuidApi::class)
fun addWallet(
name: String,
uri: Nip47WalletConnect.Nip47URINorm,
@@ -392,9 +394,7 @@ class WalletViewModel : ViewModel() {
val entry =
NwcWalletEntryNorm(
id =
java.util.UUID
.randomUUID()
.toString(),
Uuid.random().toString(),
name = name.ifBlank { "Wallet" },
uri = uri,
)
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LocalInlineQuoteRenderer
import com.vitorpamplona.amethyst.commons.ui.components.LocalTranslationPlatform
import com.vitorpamplona.amethyst.commons.ui.note.platform.LocalNotePlatform
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppPlatform
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextPlatform
import com.vitorpamplona.amethyst.commons.ui.screen.DisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.collectDisplaySettings
@@ -57,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.components.DefaultInlineQuoteRenderer
import com.vitorpamplona.amethyst.ui.components.FlavorTranslationPlatform
import com.vitorpamplona.amethyst.ui.note.platform.AndroidNotePlatform
import com.vitorpamplona.amethyst.ui.platform.AndroidAppPlatform
import com.vitorpamplona.amethyst.ui.platform.AndroidAppServices
val chartLightColors =
VicoTheme(
@@ -141,6 +143,7 @@ fun AmethystTheme(
LocalTranslationPlatform provides FlavorTranslationPlatform,
LocalNotePlatform provides AndroidNotePlatform,
LocalAppPlatform provides AndroidAppPlatform,
LocalAppServices provides AndroidAppServices,
content = content,
)
}
@@ -0,0 +1,39 @@
/*
* 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.favorites
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
/**
* The addressable coordinate `kind:pubkey:dtag` used to key an nsite/napplet favorite. Stored
* instead of the content hash so the favorite survives routine code/manifest updates.
*/
fun favoriteCoordinateOf(event: Event): String {
val dTag =
when (event) {
is NamedNappletEvent -> event.identifier()
is NamedSiteEvent -> event.identifier()
else -> ""
}
return "${event.kind}:${event.pubKey}:$dTag"
}
@@ -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.commons.service
import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
/**
* App-wide stores that outlive any one account and that screens read directly: the favorites
* row, the browser's history and favicons, what connected apps may do, and the Tor settings.
* Account-scoped services reach screens through the account's ViewModel instead.
*
* A front end implements it over its own modules; shared UI reads it through `LocalAppServices`.
*/
interface AppServices {
val favoriteApps: FavoriteAppsRegistry
val browserHistory: BrowserHistoryRegistry
val browserIcons: BrowserIconRegistry
/** What each napplet/nsite has been granted, per account. */
val nappletPermissionLedger: NappletPermissionLedger
/** What each remote-signer client has been granted. */
val signerPermissionStore: NostrSignerPermissionStore
val torSettings: TorSettingsFlow
}
@@ -0,0 +1,24 @@
/*
* 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.util
/** [value] with the locale's digit grouping: `1,234,567` in English, `1.234.567` in German. */
expect fun formatGrouped(value: Long): String
@@ -23,10 +23,15 @@ package com.vitorpamplona.amethyst.commons.viewmodels
import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure
import com.vitorpamplona.amethyst.commons.tor.MoneyOpRelayRouting
import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthSnapshot
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import kotlinx.collections.immutable.PersistentMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
* What the account's ViewModel needs from the process it runs in: app-wide services that outlive
@@ -55,6 +60,12 @@ interface AccountViewModelHost {
/** Routes a payment's relays under the money-operations Tor preference while it runs. */
val moneyOpRelays: MoneyOpRelayRouting
/** Which relays go through Tor under the user's settings, kept current. */
val torRelayEvaluation: StateFlow<TorRelayEvaluation>
/** NIP-42 authentication state per relay, across every logged-in account. */
val relayAuthState: StateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>
/** Public keys of every account saved on this device. */
val savedAccounts: Flow<Set<HexKey>>
@@ -0,0 +1,31 @@
/*
* 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.util
import platform.Foundation.NSNumber
import platform.Foundation.NSNumberFormatter
import platform.Foundation.NSNumberFormatterDecimalStyle
import platform.Foundation.numberWithLongLong
actual fun formatGrouped(value: Long): String {
val formatter = NSNumberFormatter().apply { numberStyle = NSNumberFormatterDecimalStyle }
return formatter.stringFromNumber(NSNumber.numberWithLongLong(value)) ?: value.toString()
}
@@ -0,0 +1,25 @@
/*
* 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.util
import java.text.NumberFormat
actual fun formatGrouped(value: Long): String = NumberFormat.getNumberInstance().format(value)
+4
View File
@@ -189,6 +189,10 @@ kotlin {
// org.jetbrains.androidx.lifecycle variant or accept a
// platform-specific ViewModel access pattern on iOS.
implementation(libs.androidx.lifecycle.viewmodel.compose)
// ZXing core encodes the QR codes the shared QrCodeDrawer shows. Pure Java,
// Apache-2.0, and already linked by both apps.
implementation(libs.zxing)
}
}
@@ -18,23 +18,15 @@
* 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.qrcode
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
import com.vitorpamplona.amethyst.commons.ui.components.getActivityWindow
/**
* Pins the screen to full brightness, and awake, while a QR code is on it.
*
* Half of "this QR code will not scan" is the phone showing it. A dark-mode OLED at the
* auto-brightness the room asked for can put so little contrast between the black and white
* modules that the other camera's binarizer cannot separate them — and the person holding it has
* no idea that is the problem, because to a human eye the code looks perfectly clear.
*/
@Composable
fun KeepScreenBrightAndAwake() {
actual fun KeepScreenBrightAndAwake() {
val view = LocalView.current
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
@@ -6486,4 +6486,6 @@
<string name="cordn_voice_play">Přehrát hlasovou zprávu</string>
<string name="cordn_voice_record">Nahrát hlasovou zprávu</string>
<string name="cordn_voice_stop">Zastavit a odeslat</string>
<string name="browser_address_hint">Hledat nebo zadat adresu</string>
<string name="napplet_untitled">Nepojmenovaný nApplet</string>
</resources>
@@ -3034,4 +3034,6 @@
<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">Der Lightning-Service des Empfängers unter %1$s ist nicht verfügbar. Er wurde aus der Lightning-Adresse "%2$s" berechnet. Fehler: %3$s. Überprüfen Sie, ob der Server online ist und ob die Lightning-Adresse korrekt ist</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Kann keine Blitzrechnung von %1$s erstellen: Element pr nicht im resultierenden JSON gefunden.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Blitzrechnung konnte nicht erstellt werden. Nachricht von %1$s: %2$s</string>
<string name="browser_address_hint">Suchen oder Adresse eingeben</string>
<string name="napplet_untitled">Unbenanntes nApplet</string>
</resources>
@@ -2995,4 +2995,6 @@
<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">La Lightning-servo de la ricevanto ĉe %1$s ne disponeblas. Ĝi estis kalkulita el la Lightning-adreso „%2$s". Eraro: %3$s. Kontrolu ĉu la servilo funkcias kaj ĉu la Lightning-adreso estas ĝusta</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Ne eblas krei Lightning-fakturon de %1$s: Elemento pr ne trovita en la rezulta JSON.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Ne eblas krei Lightning-fakturon. Mesaĝo de %1$s: %2$s</string>
<string name="browser_address_hint">Serĉi aŭ enigi adreson</string>
<string name="napplet_untitled">Sennoma nApplet</string>
</resources>
@@ -2995,4 +2995,6 @@
<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">El servicio de Lightning del destinatario en %1$s no está disponible. Se calculó a partir de la dirección de Lightning "%2$s". Error: %3$s. Comprueba si el servidor está activo y si la dirección de Lightning es correcta.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">No se pudo crear una factura de Lightning desde %1$s: no se encontró el elemento pr en el JSON resultante.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">No se pudo crear una factura de Lightning. Mensaje de %1$s: %2$s.</string>
<string name="browser_address_hint">Buscar o introducir dirección</string>
<string name="napplet_untitled">NApplet sin título</string>
</resources>
@@ -2995,4 +2995,6 @@
<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">خدمات لایتنینگ %1$s گیرنده دردسترس نیست. از آدرس لایتنینگ "%2$s" محاسبه شد. خطا: %3$s. بررسی کنید آیا سرور کار می کند و آیا آدرس لایتنینگ صحیح است.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">نمی توان صورتحساب لایتنینگ از %1$s ساخت. المنت pr در JSON بدست آمده یافت نشد.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">صورت حساب لایتنینگ ساخته نشد. پیغام از %1$s: %2$s</string>
<string name="browser_address_hint">جستجو یا وارد کردن آدرس</string>
<string name="napplet_untitled">nApplet بی‌عنوان</string>
</resources>
@@ -2995,4 +2995,6 @@
<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">Le service lightning du récepteur à %1$s n'est pas disponible. Il a été calculé à partir de l'adresse lightning "%2$s". Erreur: %3$s. Vérifiez si le serveur est en ligne et si l'adresse lightning est correcte</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Impossible de créer une facture lightning avec %1$s. Élément pr introuvable dans le JSON résultant.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Impossible de créer une facture lightning. Message de %1$s: %2$s</string>
<string name="browser_address_hint">Rechercher ou saisir une adresse</string>
<string name="napplet_untitled">nApplet sans titre</string>
</resources>
@@ -2958,4 +2958,6 @@
<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">Layanan lightning penerima pada %1$s tidak tersedia. Itu dihitung dari alamat petir "%2$s". Kesalahan: %3$s. Periksa apakah server aktif dan apakah alamat lightning sudah benar</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Tidak dapat membuat tagihan Lightning dari %1$s: Elemen pr tidak ditemukan dalam JSON yang dihasilkan.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Tidak dapat membuat tagihan Lightning. Pesan dari %1$s: %2$s</string>
<string name="browser_address_hint">Cari atau masukkan alamat</string>
<string name="napplet_untitled">nApplet Tanpa Judul</string>
</resources>
@@ -2958,4 +2958,6 @@
<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">%1$s の受信者の Lightning サービスが利用できません。Lightning アドレス "%2$s" から計算されました。エラー: %3$s。サーバーが稼働しているか、Lightning アドレスが正しいかを確認してください</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">%1$s から Lightning 請求書を作成できませんでした: 結果の JSON に要素 pr が見つかりませんでした。</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Lightning 請求書を作成できませんでした。%1$s からのメッセージ: %2$s</string>
<string name="browser_address_hint">検索またはアドレスを入力</string>
<string name="napplet_untitled">無題の nApplet</string>
</resources>
@@ -2995,4 +2995,6 @@
<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">De Lightning-service van de ontvanger op %1$s is niet beschikbaar. Dit werd berekend vanuit Lightning-adres "%2$s". Fout: %3$s. Controleer of de server online is en het adres correct is.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Kon geen Lightning-invoice aanmaken van %1$s: element "pr" niet gevonden in JSON.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Kon geen Lightning-invoice aanmaken. Bericht van %1$s: %2$s</string>
<string name="browser_address_hint">Zoeken of adres invoeren</string>
<string name="napplet_untitled">Naamloze nApplet</string>
</resources>
@@ -2995,4 +2995,6 @@
<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">De Lightning service van de ontvanger bij %1$s is niet beschikbaar. Het werd berekend op basis van het Lightning Adress "%2$s". Fout: %3$s. Controleer of de server klaar is en of het Lightning Adress juist is</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Kan geen Lightning invoice aanmaken van %1$s: PR-element niet gevonden in de JSON.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Kan geen Lightning invoice aanmaken. Bericht van %1$s: %2$s</string>
<string name="browser_address_hint">Zoeken of adres invoeren</string>
<string name="napplet_untitled">Naamloze nApplet</string>
</resources>
@@ -3069,4 +3069,6 @@
<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">Lightning-сервис получателя по адресу %1$s недоступен. Он был вычислен из Lightning-адреса "%2$s". Ошибка: %3$s. Проверьте, работает ли сервер и правильно ли указан Lightning-адрес</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Не удалось создать Lightning-счёт от %1$s: элемент pr не найден в полученном JSON.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Не удалось создать Lightning-счёт. Сообщение от %1$s: %2$s</string>
<string name="browser_address_hint">Поиск или введите адрес</string>
<string name="napplet_untitled">nApplet без названия</string>
</resources>
@@ -2995,4 +2995,6 @@
<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">%1$s-ல் பெறுநரின் lightning சேவை கிடைக்கவில்லை. இது lightning முகவரி "%2$s" இலிருந்து கணக்கிடப்பட்டது. பிழை: %3$s. சர்வர் இயங்குகிறதா மற்றும் lightning முகவரி சரியானதா என சரிபாருங்கள்</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">%1$s-இலிருந்து lightning invoice உருவாக்க முடியவில்லை: முடிவு JSON-ல் pr உறுப்பு கிடைக்கவில்லை.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Lightning invoice உருவாக்க முடியவில்லை. %1$s-இலிருந்து செய்தி: %2$s</string>
<string name="browser_address_hint">தேடவும் அல்லது முகவரியை உள்ளிடவும்</string>
<string name="napplet_untitled">தலைப்பற்ற nApplet</string>
</resources>
@@ -2958,4 +2958,6 @@
<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">บริการ Lightning ของ %1$s แลจะไม่พร้อมใช้งานจาก "%2$s". Error: %3$s. โปรดตรวจสอบผู้ให้บริการ หรือที่อยู่ Lightning ให้ถูกตต้อง</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">ไม่สามารถสร้างบิลชำระในการส่ง zap จาก %1$s เนื่องจากไม่พบ PR บน JSON</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">ไม่สามารถสร้าง lightning invoice ข้อความจาก %1$s: %2$s</string>
<string name="browser_address_hint">ค้นหาหรือป้อนที่อยู่</string>
<string name="napplet_untitled">nApplet ไม่มีชื่อ</string>
</resources>
@@ -2995,4 +2995,6 @@
<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">%1$s adresindeki alıcının Lightning hizmeti kullanılamıyor. Bu adres "%2$s" Lightning adresinden hesaplandı. Hata: %3$s. Sunucunun çalışıp çalışmadığını ve Lightning adresinin doğru olup olmadığını kontrol edin.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">%1$s adresinden Lightning faturası oluşturulamadı: Sonuçtaki JSON'da pr öğesi bulunamadı.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Lightning faturası oluşturulamadı. %1$s'den gelen mesaj: %2$s</string>
<string name="browser_address_hint">Ara veya adres gir</string>
<string name="napplet_untitled">Başlıksız nApplet</string>
</resources>
@@ -3069,4 +3069,6 @@
<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">Lightning-сервіс отримувача за адресою %1$s недоступний. Він був розрахований із lightning-адреси "%2$s". Помилка: %3$s. Перевірте, чи працює сервер і чи правильна lightning-адреса</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Неможливо створити lightning-рахунок від %1$s: Елемент pr не знайдено у відповідному JSON.</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Неможливо створити lightning-рахунок. Повідомлення від %1$s: %2$s</string>
<string name="browser_address_hint">Пошук або введіть адресу</string>
<string name="napplet_untitled">NApplet без назви</string>
</resources>
@@ -2999,4 +2999,6 @@
<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">%1$s 的接收者闪电服务不可用。它是根据雷电地址"%2$s"计算的。 错误: %3$s。请检查服务器是否已经上线,闪电地址是否正确</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">无法从 %1$s 创建闪电发票:在生成的 JSON 中找不到付款要求元素。</string>
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">无法创建闪电发票。来自 %1$s 的消息:%2$s</string>
<string name="browser_address_hint">搜索或输入地址</string>
<string name="napplet_untitled">未命名nApplet</string>
</resources>
@@ -6243,4 +6243,6 @@
<string name="cordn_voice_play">Play voice note</string>
<string name="cordn_voice_record">Record a voice note</string>
<string name="cordn_voice_stop">Stop and send</string>
<string name="browser_address_hint">Search or enter address</string>
<string name="napplet_untitled">Untitled nApplet</string>
</resources>
@@ -24,9 +24,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.ScanOutcome
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
/**
* The app shell's pieces that shared screens embed but only the front end can draw yet: the
@@ -64,6 +67,42 @@ interface AppPlatform {
modifier: Modifier,
) {}
/** The camera QR scanner, full screen. [onScan] says whether the payload was used. */
@Composable
fun QrCodeScannerDialog(
onDismiss: () -> Unit,
onScan: (String) -> ScanOutcome,
) {}
/** Opens favorite apps and web links in the platform's app surface. */
@Composable
fun rememberAppLauncher(): AppLauncher = AppLauncher.None
/**
* The image model for a CORD-02 community picture: the plain URL for a url-only pointer, or a
* local copy of the decrypted blob for an encrypted one (null while it loads or if it fails).
*/
@Composable
fun rememberConcordImageModel(
pointer: ImagePointer?,
accountViewModel: AccountViewModel,
): String? = pointer?.takeIf { !it.isResolvable() }?.url?.ifBlank { null }
/** The cached icon of the napplet or nsite at [coordinate], if one was fetched. */
@Composable
fun rememberNappletIconModel(coordinate: String): String? = null
/** The icon a napplet manifest by [author] / [identifier] declares, if cached. */
@Composable
fun rememberManifestIconModel(
author: String,
identifier: String,
): String? = null
/** The favicon the browser captured for [url]'s host, if any. */
@Composable
fun rememberWebAppIconModel(url: String): String? = null
/** Draws nothing: previews, and front ends still wiring their pieces. */
object None : AppPlatform
}
@@ -96,3 +135,61 @@ fun GeohashLocationPickerContent(
onConfirm: (String) -> Unit,
modifier: Modifier = Modifier,
) = LocalAppPlatform.current.GeohashLocationPickerContent(initialGeohash, confirmLabel, onConfirm, modifier)
@Composable
fun QrCodeScannerDialog(
onDismiss: () -> Unit,
onScan: (String) -> ScanOutcome,
) = LocalAppPlatform.current.QrCodeScannerDialog(onDismiss, onScan)
@Composable
fun rememberAppLauncher(): AppLauncher = LocalAppPlatform.current.rememberAppLauncher()
@Composable
fun rememberConcordImageModel(
pointer: ImagePointer?,
accountViewModel: AccountViewModel,
): String? = LocalAppPlatform.current.rememberConcordImageModel(pointer, accountViewModel)
@Composable
fun rememberNappletIconModel(coordinate: String): String? = LocalAppPlatform.current.rememberNappletIconModel(coordinate)
@Composable
fun rememberManifestIconModel(
author: String,
identifier: String,
): String? = LocalAppPlatform.current.rememberManifestIconModel(author, identifier)
@Composable
fun rememberWebAppIconModel(url: String): String? = LocalAppPlatform.current.rememberWebAppIconModel(url)
/**
* Opens a favorite app or a web link in the platform's app surface (on Android, the sandboxed
* full-screen WebView in its own task).
*/
interface AppLauncher {
/** [stillLoading] is shown when a Nostr app's defining event has not arrived yet. */
fun launch(
app: FavoriteApp,
stillLoading: String,
)
/** [preferTor] forces Tor when available, e.g. for `.onion` hosts. */
fun launchUrl(
url: String,
preferTor: Boolean = false,
)
/** Launches nothing: front ends without an app surface yet. */
object None : AppLauncher {
override fun launch(
app: FavoriteApp,
stillLoading: String,
) {}
override fun launchUrl(
url: String,
preferTor: Boolean,
) {}
}
}

Some files were not shown because too many files have changed in this diff Show More