mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
Merge remote-tracking branch 'origin/main' into claude/zap-onchain-balance-check-gyc19z
This commit is contained in:
+27
-1
@@ -263,7 +263,33 @@ Commit the regenerated `material_symbols_outlined.ttf` alongside your
|
||||
at runtime because the glyph is not in the bundled font.
|
||||
|
||||
Reusing a codepoint already present in `MaterialSymbols.kt` does NOT require
|
||||
regenerating. See `tools/material-symbols-subset/README.md` for details and
|
||||
regenerating.
|
||||
|
||||
### Amethyst's own icons are also a font
|
||||
|
||||
The icons in `commons/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are
|
||||
**also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn
|
||||
as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into
|
||||
a per-instance cached layer, so a feed re-rasterised the same glyph once per card;
|
||||
a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**,
|
||||
overrun P90 **-17.4%** on the feed scroll benchmark.
|
||||
|
||||
**MANDATORY:** whenever you add or change an icon under `commons/.../commons/icons/`,
|
||||
regenerate the font *and* its codepoint table together:
|
||||
|
||||
```bash
|
||||
python3 tools/icon-font/build_icon_font.py \
|
||||
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
|
||||
commons/src/commonMain/composeResources/font/amethyst_icons.ttf \
|
||||
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
|
||||
```
|
||||
|
||||
Both outputs must be committed together: codepoints are assigned in filename order,
|
||||
so adding an icon renumbers the ones after it, and a stale `AmethystIcons.kt` then
|
||||
points at the wrong glyph. Needs `fonttools` (`pip install fonttools`). The script
|
||||
prints any icon it could not convert — an icon that is skipped must keep using its
|
||||
`ImageVector`.
|
||||
See `tools/material-symbols-subset/README.md` for details and
|
||||
prerequisites (`pip install fonttools brotli`).
|
||||
|
||||
## Code Formatting
|
||||
|
||||
@@ -3546,6 +3546,15 @@ class Account(
|
||||
|
||||
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
|
||||
|
||||
/**
|
||||
* Blocks a single relay, leaving the rest of the kind-10006 list alone.
|
||||
*
|
||||
* Once published, [com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient]
|
||||
* strips the relay from every REQ, COUNT and publish, so the pool drops the socket as soon as
|
||||
* the subscriptions that wanted it are recomputed.
|
||||
*/
|
||||
suspend fun blockRelay(relay: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(blockedRelayList.addRelay(relay))
|
||||
|
||||
/**
|
||||
* Returns all known signed replaceable events that configure this account
|
||||
* (profile, contact list, relay lists, mute list, bookmarks, etc.). Events
|
||||
|
||||
+14
@@ -73,6 +73,20 @@ class BlockedRelayListState(
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Adds [relay] to the kind-10006 list, keeping whatever is already there.
|
||||
*
|
||||
* Callers that only want to block one relay (the NOTIFY prompt's "Block Relay" button, for
|
||||
* instance) must not rebuild the list from a snapshot they captured earlier: the list is
|
||||
* shared across clients and may have grown since. Reading the current note here keeps the
|
||||
* add additive.
|
||||
*/
|
||||
suspend fun addRelay(relay: NormalizedRelayUrl): BlockedRelayListEvent {
|
||||
val current = normalizeBlockedRelayListWithBackup(blockedListNote).toMutableList()
|
||||
if (relay !in current) current.add(relay)
|
||||
return saveRelayList(current)
|
||||
}
|
||||
|
||||
suspend fun saveRelayList(blockedRelays: List<NormalizedRelayUrl>): BlockedRelayListEvent {
|
||||
if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException()
|
||||
val relayListForBlocked = getBlockedRelayList()
|
||||
|
||||
+20
@@ -57,6 +57,26 @@ fun DisplayNotifyMessages(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onDismiss = { requests.dismissPaymentRequest(request) },
|
||||
onBlockRelay =
|
||||
if (accountViewModel.isWriteable()) {
|
||||
{
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.blockRelay(request.relayUrl)
|
||||
|
||||
// Reached only once the block is signed and published, because
|
||||
// reportSignerErrors swallows a refused or timed-out signature without
|
||||
// a toast: dismissing up front would close the dialog on a relay that
|
||||
// is still unblocked and leave the user no sign anything failed. The
|
||||
// prompt staying up is the feedback.
|
||||
//
|
||||
// Every queued prompt from the relay goes at once, not just the one on
|
||||
// screen — a paid relay files one NOTIFY per rejected AUTH.
|
||||
requests.dismissAllFrom(request.relayUrl)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -30,6 +30,7 @@ import androidx.compose.material3.ButtonColors
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -55,6 +56,11 @@ fun NotifyRequestDialog(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onDismiss: () -> Unit,
|
||||
/**
|
||||
* Adds the relay that sent this message to the NIP-51 kind:10006 blocked list. Null hides the
|
||||
* button — there is nothing to block for a read-only account, which cannot sign the list.
|
||||
*/
|
||||
onBlockRelay: (() -> Unit)? = null,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -94,5 +100,29 @@ fun NotifyRequestDialog(
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton =
|
||||
onBlockRelay?.let {
|
||||
{
|
||||
TextButton(
|
||||
onClick = it,
|
||||
contentPadding = PaddingValues(horizontal = Size16dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Block,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.notify_block_relay),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+9
-2
@@ -101,8 +101,15 @@ class NotifyCoordinator(
|
||||
// Consume the correlation so a later, unrelated NOTIFY can't reuse a stale attribution.
|
||||
// An unattributable NOTIFY (none of our auths were rejected here) is dropped rather than
|
||||
// risk surfacing it under the wrong account.
|
||||
val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey)
|
||||
account?.relayNotifications?.addPaymentRequestIfNew(message, relay)
|
||||
val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey) ?: return
|
||||
|
||||
// A relay the user has already blocked doesn't get to keep prompting. The pool drops the
|
||||
// socket once the subscriptions that wanted this relay are recomputed, but frames already
|
||||
// in flight can still arrive in that window — and the whole point of the dialog's "Block
|
||||
// Relay" button is that the dialog stops coming back.
|
||||
if (relay in account.blockedRelayList.flow.value) return
|
||||
|
||||
account.relayNotifications.addPaymentRequestIfNew(message, relay)
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
+26
-6
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
@Stable
|
||||
@@ -38,12 +39,12 @@ class NotifyRequestsCache {
|
||||
}
|
||||
|
||||
fun addPaymentRequestIfNew(paymentRequest: NotifyRequest) {
|
||||
if (
|
||||
!this.transientPaymentRequests.value.contains(paymentRequest) &&
|
||||
!this.transientPaymentRequestDismissals.value.contains(paymentRequest)
|
||||
) {
|
||||
this.transientPaymentRequests.value += paymentRequest
|
||||
}
|
||||
if (this.transientPaymentRequestDismissals.value.contains(paymentRequest)) return
|
||||
|
||||
// `update` rather than `value +=`: NOTIFYs are filed from the relay's socket coroutine
|
||||
// while dismissals run from the UI, and a plain read-modify-write silently drops one of
|
||||
// two concurrent edits — either losing a prompt or resurrecting a dismissed one.
|
||||
this.transientPaymentRequests.update { if (paymentRequest in it) it else it + paymentRequest }
|
||||
}
|
||||
|
||||
fun dismissPaymentRequest(request: NotifyRequest) {
|
||||
@@ -52,4 +53,23 @@ class NotifyRequestsCache {
|
||||
this.transientPaymentRequestDismissals.update { it + request }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every pending prompt from [relayUrl] at once.
|
||||
*
|
||||
* Used when the user blocks the relay: a relay that asks for payment usually queues one NOTIFY
|
||||
* per rejected AUTH, so dismissing them one at a time would keep re-showing the dialog for a
|
||||
* relay the user just told us never to talk to again.
|
||||
*/
|
||||
fun dismissAllFrom(relayUrl: NormalizedRelayUrl) {
|
||||
// getAndUpdate so the drain and the snapshot of what was drained are one atomic step: a
|
||||
// NOTIFY filed by the socket coroutine between a separate read and write would otherwise
|
||||
// be dropped from the pending set without ever being recorded as dismissed.
|
||||
val before = this.transientPaymentRequests.getAndUpdate { pending -> pending.filterNotTo(mutableSetOf()) { it.relayUrl == relayUrl } }
|
||||
|
||||
val dismissed = before.filterTo(mutableSetOf()) { it.relayUrl == relayUrl }
|
||||
if (dismissed.isEmpty()) return
|
||||
|
||||
this.transientPaymentRequestDismissals.update { it + dismissed }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ import com.vitorpamplona.amethyst.commons.icons.Reposted
|
||||
import com.vitorpamplona.amethyst.commons.icons.Search
|
||||
import com.vitorpamplona.amethyst.commons.icons.Zap
|
||||
import com.vitorpamplona.amethyst.commons.icons.following
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.AmethystIconGlyph
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.AmethystIcons
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -109,8 +111,8 @@ fun LikedIcon(
|
||||
modifier: Modifier,
|
||||
tint: Color = Color.Unspecified,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Liked,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Liked,
|
||||
contentDescription = stringRes(id = R.string.like_description),
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
@@ -135,8 +137,8 @@ fun LikeIcon(
|
||||
iconSizeModifier: Modifier,
|
||||
grayTint: Color,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Like,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Like,
|
||||
contentDescription = stringRes(id = R.string.like_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = grayTint,
|
||||
@@ -148,8 +150,8 @@ fun RepostIcon(
|
||||
modifier: Modifier,
|
||||
tint: Color = Color.Unspecified,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Repost,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Repost,
|
||||
contentDescription = stringRes(id = R.string.boost_or_quote_description),
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
@@ -161,8 +163,8 @@ fun RepostedIcon(
|
||||
modifier: Modifier,
|
||||
tint: Color = Color.Unspecified,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Reposted,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Reposted,
|
||||
contentDescription = stringRes(id = R.string.boost_or_quote_description),
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
@@ -318,8 +320,8 @@ fun CommentIcon(
|
||||
iconSizeModifier: Modifier,
|
||||
tint: Color,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Reply,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Reply,
|
||||
contentDescription = stringRes(id = R.string.reply_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = tint,
|
||||
@@ -350,8 +352,8 @@ fun SearchIcon(
|
||||
modifier: Modifier,
|
||||
tint: Color = Color.Unspecified,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Search,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Search,
|
||||
contentDescription = stringRes(id = R.string.search_button),
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
|
||||
+57
-87
@@ -20,48 +20,24 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Lightning-family target types Amethyst can pay in-app through the Send Payment screen. */
|
||||
private val LIGHTNING_TARGET_TYPES = setOf("lightning", "ln", "lnurl")
|
||||
@@ -97,6 +73,15 @@ fun inAppPaymentRouteFor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The URI an external wallet app should receive for [target]: the type's own
|
||||
* scheme (`bitcoin:`, `lightning:`, `https://cash.app/…`) and RFC 8905
|
||||
* `payto://` for types Amethyst has no dedicated scheme for. Shared with the
|
||||
* payment-target dialog so the same pill hands off to the same app wherever
|
||||
* it is tapped.
|
||||
*/
|
||||
fun paymentTargetUri(target: PaymentTarget): String = paymentTargetStyleFor(target.type).uriFor(target.authority)
|
||||
|
||||
/**
|
||||
* Chip for a NIP-A3 payment target. Rendered inside [DisplayPaymentRailChips]'s
|
||||
* FlowRow alongside the wallet-rail chips so all payment chips share one
|
||||
@@ -111,72 +96,57 @@ fun PaymentTargetChip(
|
||||
) {
|
||||
val style = remember(target.type) { paymentTargetStyleFor(target.type) }
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val copyLabel = stringRes(R.string.copy_to_clipboard)
|
||||
val copiedMessage = stringRes(R.string.copied_to_clipboard)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = style.color.copy(alpha = 0.10f),
|
||||
border = BorderStroke(1.dp, style.color.copy(alpha = 0.35f)),
|
||||
modifier =
|
||||
Modifier.combinedClickable(
|
||||
onClick = {
|
||||
// Targets one of the user's in-app wallets can pay (lightning,
|
||||
// bitcoin) go to the Send Payment screen, which collects the
|
||||
// amount and pays this exact target; everything else hands off
|
||||
// to an external wallet app via its payment URI.
|
||||
val inAppRoute = inAppPaymentRouteFor(baseUser.pubkeyHex, target)
|
||||
if (inAppRoute != null) {
|
||||
nav.nav(inAppRoute)
|
||||
} else {
|
||||
runCatching { uriHandler.openUri(style.uriFor(target.authority)) }
|
||||
.onFailure {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.error_dialog_payment_error,
|
||||
R.string.no_payment_app_found_for_type,
|
||||
style.label,
|
||||
)
|
||||
}
|
||||
PaymentTargetPill(
|
||||
target = target,
|
||||
onClick = {
|
||||
// Targets one of the user's in-app wallets can pay (lightning,
|
||||
// bitcoin) go to the Send Payment screen, which collects the
|
||||
// amount and pays this exact target; everything else hands off
|
||||
// to an external wallet app via its payment URI.
|
||||
val inAppRoute = inAppPaymentRouteFor(baseUser.pubkeyHex, target)
|
||||
if (inAppRoute != null) {
|
||||
nav.nav(inAppRoute)
|
||||
} else {
|
||||
runCatching { uriHandler.openUri(style.uriFor(target.authority)) }
|
||||
.onFailure {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.error_dialog_payment_error,
|
||||
R.string.no_payment_app_found_for_type,
|
||||
style.label,
|
||||
)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
clipboard.setText(target.authority)
|
||||
Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onLongClickLabel = copyLabel,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pill for a single NIP-A3 payment target: the type's icon and tinted
|
||||
* label followed by the shortened authority, with a long-press copy of the
|
||||
* full authority. Shared by the profile's payment rail and the payment-target
|
||||
* dialog so a target looks the same wherever it shows up.
|
||||
*/
|
||||
@Composable
|
||||
fun PaymentTargetPill(
|
||||
target: PaymentTarget,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val style = remember(target.type) { paymentTargetStyleFor(target.type) }
|
||||
|
||||
ProfilePaymentChip(
|
||||
color = style.color,
|
||||
label = style.label,
|
||||
detail = remember(target.authority) { shortAddress(target.authority) },
|
||||
copyValue = target.authority,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = style.symbol,
|
||||
contentDescription = style.label,
|
||||
tint = style.color,
|
||||
modifier = Size16Modifier,
|
||||
)
|
||||
Text(
|
||||
text = style.label,
|
||||
color = style.color,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = shortAddress(target.authority),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 180.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
symbol = style.symbol,
|
||||
contentDescription = null,
|
||||
tint = style.color,
|
||||
modifier = Size16Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-34
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
|
||||
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -47,11 +47,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -72,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -153,6 +151,7 @@ fun PaymentTargetsDialog(
|
||||
payInApp: ((PaymentTarget) -> Boolean)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
@@ -188,15 +187,13 @@ fun PaymentTargetsDialog(
|
||||
},
|
||||
onPay = {
|
||||
if (payInApp?.invoke(target) != true) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.startActivity(intent)
|
||||
onDismiss()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
errorMessage = stringRes(context, R.string.no_payment_app_found)
|
||||
}
|
||||
// Same handoff the profile chip does: the type's own
|
||||
// scheme when it has one (payto:// only as the
|
||||
// fallback), and no task flags — CLEAR_TASK used to
|
||||
// wipe whatever the wallet app already had open.
|
||||
runCatching { uriHandler.openUri(paymentTargetUri(target)) }
|
||||
.onSuccess { onDismiss() }
|
||||
.onFailure { errorMessage = stringRes(context, R.string.no_payment_app_found) }
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -232,19 +229,13 @@ private fun PaymentTargetRow(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = target.type.replaceFirstChar(Char::titlecase),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = target.authority,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// Same pill the profile page renders for this target: type icon,
|
||||
// tinted type label and the shortened authority, instead of the raw
|
||||
// wallet id spelled out over two lines. Tapping it pays and long-press
|
||||
// copies, exactly like on the profile, so the row carries no separate
|
||||
// pay button — three icon buttons left the address 0dp of width.
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
PaymentTargetPill(target = target, onClick = onPay)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(onClick = onShowQr) {
|
||||
@@ -263,14 +254,6 @@ private fun PaymentTargetRow(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPay) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
contentDescription = stringRes(R.string.payment_targets),
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -290,6 +290,10 @@ fun ProfilePaymentChip(
|
||||
color = color,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
// Narrow hosts (the payment-target dialog's row) would otherwise
|
||||
// wrap a long type label into a two-line pill.
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (detail != null) {
|
||||
Text(
|
||||
|
||||
@@ -65,7 +65,7 @@ import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.patrykandpatrick.vico.compose.common.VicoTheme
|
||||
import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons
|
||||
import com.vitorpamplona.amethyst.model.AccentColorType
|
||||
import com.vitorpamplona.amethyst.model.FontFamilyType
|
||||
import com.vitorpamplona.amethyst.model.FontSizeType
|
||||
@@ -738,7 +738,7 @@ fun AmethystTheme(
|
||||
typography = typography,
|
||||
shapes = Shapes,
|
||||
content = {
|
||||
ProvideMaterialSymbols {
|
||||
ProvideAppIcons {
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides scaledDensity,
|
||||
LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)),
|
||||
|
||||
@@ -3123,6 +3123,7 @@
|
||||
</string>
|
||||
|
||||
<string name="payment_required_title">Message from %1$s</string>
|
||||
<string name="notify_block_relay">Block Relay</string>
|
||||
<string name="thread_title">Thread</string>
|
||||
<string name="send_the_seller_a_message">Send the seller a message</string>
|
||||
<string name="hi_seller_is_this_still_available">Hi %1$s, is this still available?</string>
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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.model.nip51Lists
|
||||
|
||||
import android.os.Looper
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Drives the real kind-10006 mutation behind the NOTIFY dialog's "Block Relay" button.
|
||||
*
|
||||
* The button hands a single relay to [BlockedRelayListState.addRelay], which read-modify-writes the
|
||||
* whole list — and [BlockedRelayListEvent.updateRelayList] *replaces* every relay tag with what it
|
||||
* is given. So "does blocking work" is really two questions this pins: the new relay lands, and the
|
||||
* relays already blocked survive. A regression here silently wipes the user's block list on their
|
||||
* next tap, which no amount of UI testing would make obvious.
|
||||
*
|
||||
* Each case uses a fresh keypair so its kind-10006 lives at its own address in the process-wide
|
||||
* [LocalCache].
|
||||
*/
|
||||
class BlockedRelayListAddRelayTest {
|
||||
private val paid = RelayUrlNormalizer.normalize("wss://paid.example.com")
|
||||
private val alsoPaid = RelayUrlNormalizer.normalize("wss://other.example.com")
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper,
|
||||
// where null == null reads as "main". Distinct mocks make it a worker thread.
|
||||
mockkStatic(Looper::class)
|
||||
every { Looper.myLooper() } returns mockk<Looper>()
|
||||
every { Looper.getMainLooper() } returns mockk<Looper>()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(Looper::class)
|
||||
}
|
||||
|
||||
private class Fixture {
|
||||
val keyPair = KeyPair()
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
val decryptionCache = BlockedRelayListDecryptionCache(signer)
|
||||
|
||||
// Stubbed rather than real: AccountSettings reaches for Resources.getSystem() to read the
|
||||
// user's spoken languages, which is null outside an Android runtime. The state only asks it
|
||||
// for the backup list (none here) and hands it updates to persist.
|
||||
val settings = mockk<AccountSettings>(relaxed = true) { every { backupBlockedRelayList } returns null }
|
||||
|
||||
val state =
|
||||
BlockedRelayListState(
|
||||
signer = signer,
|
||||
cache = LocalCache,
|
||||
decryptionCache = decryptionCache,
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
settings = settings,
|
||||
)
|
||||
|
||||
/** Mirrors what the publish path does: the signed event goes into the cache it came from. */
|
||||
fun land(event: BlockedRelayListEvent) = LocalCache.justConsumeMyOwnEvent(event)
|
||||
|
||||
suspend fun blockedIn(event: BlockedRelayListEvent) = BlockedRelayListDecryptionCache(signer).relays(event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockingTheFirstRelayCreatesTheList() =
|
||||
runTest {
|
||||
val f = Fixture()
|
||||
|
||||
val event = f.state.addRelay(paid)
|
||||
|
||||
assertEquals(BlockedRelayListEvent.KIND, event.kind)
|
||||
assertEquals(setOf(paid), f.blockedIn(event))
|
||||
}
|
||||
|
||||
/** The one that matters: a second tap must not throw away the first block. */
|
||||
@Test
|
||||
fun blockingASecondRelayKeepsTheFirst() =
|
||||
runTest {
|
||||
val f = Fixture()
|
||||
f.land(f.state.addRelay(paid))
|
||||
|
||||
val event = f.state.addRelay(alsoPaid)
|
||||
|
||||
assertEquals(setOf(paid, alsoPaid), f.blockedIn(event))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockingAnAlreadyBlockedRelayDoesNotDuplicateIt() =
|
||||
runTest {
|
||||
val f = Fixture()
|
||||
f.land(f.state.addRelay(paid))
|
||||
|
||||
val event = f.state.addRelay(paid)
|
||||
|
||||
assertEquals(setOf(paid), f.blockedIn(event))
|
||||
assertEquals(1, BlockedRelayListDecryptionCache(f.signer).relays(event).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocked relays are private tags. Leaking them to public tags would publish which paid relays
|
||||
* the user walked away from to anyone who reads their kind-10006.
|
||||
*/
|
||||
@Test
|
||||
fun blockedRelaysStayInEncryptedPrivateTags() =
|
||||
runTest {
|
||||
val f = Fixture()
|
||||
f.land(f.state.addRelay(paid))
|
||||
|
||||
val event = f.state.addRelay(alsoPaid)
|
||||
|
||||
assertTrue("blocked relays must not appear in public tags", event.publicRelays().isEmpty())
|
||||
assertTrue(
|
||||
"no relay url may appear in the cleartext tag array",
|
||||
event.tags.none { tag -> tag.any { it.contains("paid.example.com") || it.contains("other.example.com") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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.service.relayClient.notifyCommand.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NotifyRequestsCacheTest {
|
||||
private val paid = NormalizedRelayUrl("wss://paid.relay/")
|
||||
private val other = NormalizedRelayUrl("wss://other.relay/")
|
||||
|
||||
@Test
|
||||
fun blockingARelayDropsEveryPendingPromptFromIt() {
|
||||
val cache = NotifyRequestsCache()
|
||||
cache.addPaymentRequestIfNew("Pay up", paid)
|
||||
cache.addPaymentRequestIfNew("Still unpaid", paid)
|
||||
cache.addPaymentRequestIfNew("Unrelated", other)
|
||||
|
||||
cache.dismissAllFrom(paid)
|
||||
|
||||
assertEquals(
|
||||
setOf(NotifyRequest(other, "Unrelated")),
|
||||
cache.transientPaymentRequests.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDismissedPromptStaysDismissedWhenTheRelayRepeatsIt() {
|
||||
val cache = NotifyRequestsCache()
|
||||
cache.addPaymentRequestIfNew("Pay up", paid)
|
||||
|
||||
cache.dismissAllFrom(paid)
|
||||
cache.addPaymentRequestIfNew("Pay up", paid)
|
||||
|
||||
assertTrue(cache.transientPaymentRequests.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aConcurrentPromptFromTheSameRelayIsNotSilentlySwallowed() {
|
||||
val cache = NotifyRequestsCache()
|
||||
cache.addPaymentRequestIfNew("Pay up", paid)
|
||||
|
||||
// Stands in for a NOTIFY landing on the socket coroutine while the UI drains the relay:
|
||||
// whatever survives the drain must still be reachable, never removed-but-unrecorded.
|
||||
cache.dismissAllFrom(paid)
|
||||
cache.addPaymentRequestIfNew("A different demand", paid)
|
||||
|
||||
val pending = cache.transientPaymentRequests.value
|
||||
val dismissed = cache.transientPaymentRequestDismissals.value
|
||||
|
||||
assertEquals(setOf(NotifyRequest(paid, "Pay up")), dismissed)
|
||||
assertEquals(setOf(NotifyRequest(paid, "A different demand")), pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dismissingARelayWithNoPromptsChangesNothing() {
|
||||
val cache = NotifyRequestsCache()
|
||||
cache.addPaymentRequestIfNew("Unrelated", other)
|
||||
|
||||
cache.dismissAllFrom(paid)
|
||||
|
||||
assertEquals(
|
||||
setOf(NotifyRequest(other, "Unrelated")),
|
||||
cache.transientPaymentRequests.value,
|
||||
)
|
||||
assertTrue(cache.transientPaymentRequestDismissals.value.isEmpty())
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.icons.symbols
|
||||
|
||||
/** Amethyst's own icons as font glyphs. See the build script for why. */
|
||||
object AmethystIcons {
|
||||
val Bookmark = MaterialSymbol("\uE900")
|
||||
val Like = MaterialSymbol("\uE901")
|
||||
val Liked = MaterialSymbol("\uE902")
|
||||
val Reply = MaterialSymbol("\uE903")
|
||||
val Repost = MaterialSymbol("\uE904")
|
||||
val Reposted = MaterialSymbol("\uE905")
|
||||
val Search = MaterialSymbol("\uE906")
|
||||
val Share = MaterialSymbol("\uE907")
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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.icons.symbols
|
||||
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.amethyst_icons
|
||||
import org.jetbrains.compose.resources.Font
|
||||
import androidx.compose.material3.Icon as Material3Icon
|
||||
|
||||
/**
|
||||
* Amethyst's own icons, built into a font by `tools/icon-font/build_icon_font.py`.
|
||||
*
|
||||
* Unlike Material Symbols this is a static font — no FILL/opsz/GRAD axes — so it needs no
|
||||
* [androidx.compose.ui.text.font.FontVariation] settings. Drawing an icon as a glyph blits from
|
||||
* the shared text atlas instead of rasterising an ImageVector's paths into a per-instance cached
|
||||
* layer, which is what made the feed re-rasterise the same three glyphs once per card.
|
||||
*/
|
||||
val LocalAmethystIconsFontFamily: ProvidableCompositionLocal<FontFamily?> = staticCompositionLocalOf { null }
|
||||
|
||||
/**
|
||||
* Builds the icon FontFamily once for the subtree and exposes it via CompositionLocal, for the
|
||||
* same reason [ProvideMaterialSymbols] does: without it every call site allocates its own Font
|
||||
* wrapper and breaks its own remember cache. Nest this inside ProvideMaterialSymbols at app roots.
|
||||
*/
|
||||
@Composable
|
||||
fun ProvideAmethystIcons(content: @Composable () -> Unit) {
|
||||
val font = Font(resource = Res.font.amethyst_icons)
|
||||
val fontFamily = remember { FontFamily(font) }
|
||||
CompositionLocalProvider(LocalAmethystIconsFontFamily provides fontFamily, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws one of Amethyst's own icons as a font glyph. Tint is baked into the painter, so
|
||||
* Material3 is told not to tint again.
|
||||
*/
|
||||
@Composable
|
||||
fun AmethystIconGlyph(
|
||||
symbol: MaterialSymbol,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = LocalContentColor.current,
|
||||
) {
|
||||
Material3Icon(
|
||||
painter = rememberMaterialSymbolPainter(symbol, tint, amethystIconsFontFamily()),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun amethystIconsFontFamily(): FontFamily =
|
||||
LocalAmethystIconsFontFamily.current ?: run {
|
||||
val font = Font(resource = Res.font.amethyst_icons)
|
||||
remember { FontFamily(font) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides both icon fonts for a subtree: Material Symbols and Amethyst's own icons.
|
||||
*
|
||||
* App roots call this instead of [ProvideMaterialSymbols] so neither font is missed, and so
|
||||
* adding the second one does not re-indent the whole root composable.
|
||||
*/
|
||||
@Composable
|
||||
fun ProvideAppIcons(
|
||||
weight: Int = MaterialSymbolsDefaults.WEIGHT,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
ProvideMaterialSymbols(weight = weight) {
|
||||
ProvideAmethystIcons(content = content)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -43,8 +43,11 @@ import androidx.compose.ui.unit.LayoutDirection
|
||||
fun rememberMaterialSymbolPainter(
|
||||
symbol: MaterialSymbol,
|
||||
tint: Color = LocalContentColor.current,
|
||||
// Lets a caller draw from a different glyph font (e.g. Amethyst's own icon font) while
|
||||
// reusing this painter, its shared TextMeasurer and its caching.
|
||||
family: FontFamily? = null,
|
||||
): Painter {
|
||||
val fontFamily = materialSymbolsFontFamily()
|
||||
val fontFamily = family ?: materialSymbolsFontFamily()
|
||||
val textMeasurer = materialSymbolsTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
+15
-2
@@ -73,12 +73,25 @@ class BlockedRelayFilteringClient(
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val blocked = blockedRelays()
|
||||
delegate.publish(event, if (blocked.isEmpty()) relayList else relayList - blocked)
|
||||
delegate.publish(event, if (blocked.hitsNoneOf(relayList)) relayList else relayList - blocked)
|
||||
}
|
||||
|
||||
private fun Map<NormalizedRelayUrl, List<Filter>>.withoutBlocked(): Map<NormalizedRelayUrl, List<Filter>> {
|
||||
val blocked = blockedRelays()
|
||||
if (blocked.isEmpty()) return this
|
||||
if (blocked.hitsNoneOf(keys)) return this
|
||||
return filterKeys { it !in blocked }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether none of the blocked relays appear in [targets] — i.e. whether the caller can be
|
||||
* handed its own collection back untouched.
|
||||
*
|
||||
* Worth the extra scan because this runs on every REQ, COUNT and publish (filter assemblers
|
||||
* rebuild their per-relay targets constantly) while a blocked relay is, by definition, one the
|
||||
* app has stopped aiming at — so "the block list is non-empty but irrelevant to this call" is
|
||||
* the overwhelmingly common case. Without the check, `filterKeys` / `minus` allocate and copy
|
||||
* the whole collection every time just to reproduce it unchanged. A hash lookup per target and
|
||||
* no allocation beats an allocation plus a full copy.
|
||||
*/
|
||||
private fun Set<NormalizedRelayUrl>.hitsNoneOf(targets: Collection<NormalizedRelayUrl>): Boolean = isEmpty() || targets.none { it in this }
|
||||
}
|
||||
|
||||
+19
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
|
||||
class BlockedRelayFilteringClientTest {
|
||||
private val good = NormalizedRelayUrl("wss://good.example/")
|
||||
@@ -133,6 +134,24 @@ class BlockedRelayFilteringClientTest {
|
||||
assertEquals(emptySet(), inner.publishedRelays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBlockListThatTouchesNothingHereIsPassedThroughWithoutCopying() {
|
||||
val inner = RecordingClient()
|
||||
// Non-empty block list, but none of it is aimed at on this call — the common case once the
|
||||
// user has blocked anything at all.
|
||||
val client = BlockedRelayFilteringClient(inner) { setOf(blocked) }
|
||||
|
||||
val filters = mapOf(good to listOf(Filter()), alsoGood to listOf(Filter()))
|
||||
val relays = setOf(good, alsoGood)
|
||||
client.subscribe("sub", filters, null)
|
||||
client.publish(event(), relays)
|
||||
|
||||
// Identity, not just equality: reproducing an unchanged map/set costs an allocation and a
|
||||
// full copy on a path that runs on every REQ and publish.
|
||||
assertSame(filters, inner.subscribedFilters)
|
||||
assertSame(relays, inner.publishedRelays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockSetIsReadPerCallSoLaterChangesApply() {
|
||||
val inner = RecordingClient()
|
||||
|
||||
@@ -82,7 +82,7 @@ import androidx.compose.ui.window.rememberWindowState
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
|
||||
import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings
|
||||
@@ -1254,7 +1254,7 @@ private fun AppInner(
|
||||
.rememberSystemDark(LocalAwtWindow.current)
|
||||
|
||||
com.vitorpamplona.amethyst.desktop.platform.PlatformMaterialTheme(isDark = isDark) {
|
||||
ProvideMaterialSymbols(
|
||||
ProvideAppIcons(
|
||||
weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current,
|
||||
) {
|
||||
Surface(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Amethyst icon font
|
||||
|
||||
Builds `amethyst_icons.ttf` from the Kotlin `ImageVector` icons in
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/`.
|
||||
|
||||
## Why
|
||||
|
||||
`Icon(imageVector = …)` calls `rememberVectorPainter`, and a `VectorPainter`
|
||||
rasterises its paths into a cached graphics layer **per instance**. A feed therefore
|
||||
re-rasterised the same handful of glyphs once for every card scrolled in. A font glyph
|
||||
is a blit from the shared text atlas instead, shared across every call site in the app
|
||||
for free — no `CompositionLocal` plumbing, no per-screen scoping.
|
||||
|
||||
Measured on the uniform-corpus macrobenchmark (SM-T220, three arms, 0.2% noise floor):
|
||||
|
||||
| approach | frame P90 | overrun P90 | artwork |
|
||||
|---|---|---|---|
|
||||
| one shared `VectorPainter` per icon | −8.2% | −14.2% | unchanged |
|
||||
| MaterialSymbols glyph substitutes | −10.4% | −16.0% | **changes** |
|
||||
| **this font** | **−10.7%** | **−17.4%** | unchanged |
|
||||
| ceiling: draw no icons at all | −12.7% | −22.9% | n/a |
|
||||
|
||||
## Usage
|
||||
|
||||
pip install fonttools
|
||||
python3 tools/icon-font/build_icon_font.py <icons-dir> <out.ttf> <out.kt>
|
||||
|
||||
See the "Amethyst's own icons are also a font" section of `.claude/CLAUDE.md` for the
|
||||
mandatory regeneration step and why both outputs must be committed together.
|
||||
|
||||
## How it works
|
||||
|
||||
The `ImageVector` builder DSL maps 1:1 onto SVG path commands (`moveTo` → `M`,
|
||||
`curveToRelative` → `c`, …; none of the icons use `arcTo`), so the script extracts the
|
||||
path data, emits an SVG `d` string, and draws it into a TrueType glyph via fontTools —
|
||||
converting cubics to quadratics and flipping the y axis, since SVG grows downward and
|
||||
font outlines grow upward from the baseline.
|
||||
|
||||
Font metrics deliberately mirror the bundled `material_symbols_outlined.ttf`
|
||||
(unitsPerEm 960, ascent 1056, descent −96, advance 960) so the glyphs align with
|
||||
existing MaterialSymbols call sites and `Icon()` sizing. Generated outlines land within
|
||||
a few units of Google's own: our `Like` spans (78,94)–(882,851), their heart
|
||||
(80,120)–(880,854).
|
||||
|
||||
An icon whose path data the parser cannot reach is reported and skipped rather than
|
||||
silently emitted empty; it must keep using its `ImageVector`.
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a custom icon font from Amethyst's Kotlin ImageVector icons.
|
||||
|
||||
Why: drawing an ImageVector rasterises its paths into a per-instance cached layer,
|
||||
so a feed re-rasterises the same glyph once per card. A font glyph is a blit from
|
||||
the shared text atlas instead. Measured on the uniform-corpus macrobenchmark
|
||||
(SM-T220, 0.2% noise floor): swapping the three reaction icons to font glyphs gave
|
||||
frame P90 -10.4% vs -8.2% for per-icon shared painters, against a -12.7% ceiling.
|
||||
|
||||
This keeps Amethyst's own artwork -- it converts the existing ImageVector path data
|
||||
rather than substituting Google's glyphs, so the icons look identical.
|
||||
|
||||
Font metrics deliberately mirror the bundled material_symbols_outlined.ttf
|
||||
(unitsPerEm 960, ascent 1056, descent -96, advance 960) so the glyphs align with
|
||||
existing MaterialSymbols call sites and Icon() sizing.
|
||||
|
||||
Usage: build_icon_font.py <icons-dir> <out.ttf> <out.kt>
|
||||
"""
|
||||
import re, sys, os
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
from fontTools.pens.cu2quPen import Cu2QuPen
|
||||
from fontTools.pens.transformPen import TransformPen
|
||||
from fontTools.misc.transform import Transform
|
||||
from fontTools.svgLib.path.parser import parse_path
|
||||
|
||||
UPEM, ASCENT, DESCENT, ADVANCE = 960, 1056, -96, 960
|
||||
FIRST_CODEPOINT = 0xE900
|
||||
MAX_ERR = 1.0 # cubic->quadratic tolerance, in font units
|
||||
|
||||
# ImageVector DSL -> SVG path command. No arcTo: none of the icons use one.
|
||||
CMDS = {
|
||||
"moveTo": "M", "moveToRelative": "m",
|
||||
"lineTo": "L", "lineToRelative": "l",
|
||||
"horizontalLineTo": "H", "horizontalLineToRelative": "h",
|
||||
"verticalLineTo": "V", "verticalLineToRelative": "v",
|
||||
"curveTo": "C", "curveToRelative": "c",
|
||||
"reflectiveCurveTo": "S", "reflectiveCurveToRelative": "s",
|
||||
"quadTo": "Q", "quadToRelative": "q",
|
||||
"reflectiveQuadTo": "T", "reflectiveQuadToRelative": "t",
|
||||
"close": "Z",
|
||||
}
|
||||
CALL_RE = re.compile(r"\b(" + "|".join(CMDS) + r")\(([^()]*)\)")
|
||||
NUM_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
|
||||
|
||||
|
||||
STROKE_W_RE = re.compile(r"strokeLineWidth\s*=\s*([\d.]+)f?")
|
||||
|
||||
|
||||
def is_stroked(src: str) -> bool:
|
||||
"""True if the icon paints a stroke, not just a fill.
|
||||
|
||||
A glyph outline is filled: there is no pen width in a TrueType glyph. Converting a
|
||||
stroked icon would emit only its fill and silently change the artwork -- Zap is drawn
|
||||
as a thin outline (strokeLineWidth 1.2) and came out as a solid bolt. Such icons must
|
||||
keep their ImageVector.
|
||||
"""
|
||||
if any(float(w) > 0 for w in STROKE_W_RE.findall(src)):
|
||||
return True
|
||||
return "stroke = SolidColor" in src
|
||||
|
||||
|
||||
def kotlin_to_svg_path(src: str):
|
||||
"""Extract viewport and an SVG 'd' string from one ImageVector .kt file."""
|
||||
vw = re.search(r"viewportWidth\s*=\s*([\d.]+)f?", src)
|
||||
vh = re.search(r"viewportHeight\s*=\s*([\d.]+)f?", src)
|
||||
if vw and vh:
|
||||
viewport = (float(vw.group(1)), float(vh.group(1)))
|
||||
elif "materialIcon(" in src:
|
||||
# materialIcon() sets the viewport itself; Material's convention is 24x24.
|
||||
viewport = (24.0, 24.0)
|
||||
else:
|
||||
return None, None
|
||||
|
||||
# Only look inside the vector builder, never the @Preview composable above it.
|
||||
# Only look inside the vector builder, never the @Preview composable above it, and never
|
||||
# a helper like materialOutlinedPath() declared after it.
|
||||
start = src.find(".apply {")
|
||||
if start == -1:
|
||||
start = src.find("materialIcon(")
|
||||
body = src[start:] if start != -1 else src
|
||||
end = body.find("\ninline fun ")
|
||||
if end != -1:
|
||||
body = body[:end]
|
||||
|
||||
parts = []
|
||||
for m in CALL_RE.finditer(body):
|
||||
cmd, args = m.group(1), m.group(2)
|
||||
letter = CMDS[cmd]
|
||||
if cmd == "close":
|
||||
parts.append("Z")
|
||||
continue
|
||||
nums = NUM_RE.findall(args)
|
||||
if not nums:
|
||||
continue
|
||||
parts.append(letter + " " + " ".join(nums))
|
||||
return viewport, " ".join(parts)
|
||||
|
||||
|
||||
def build_glyph(d: str, viewport):
|
||||
vw, vh = viewport
|
||||
# Uniform scale on the larger axis keeps non-square viewports undistorted.
|
||||
s = UPEM / max(vw, vh)
|
||||
pen = TTGlyphPen(None)
|
||||
# y flips: SVG grows downward, font outlines grow upward from the baseline.
|
||||
tp = TransformPen(Cu2QuPen(pen, MAX_ERR), Transform(s, 0, 0, -s, 0, vh * s))
|
||||
parse_path(d, tp)
|
||||
return pen.glyph()
|
||||
|
||||
|
||||
def main(icons_dir, out_ttf, out_kt):
|
||||
files = sorted(f for f in os.listdir(icons_dir) if f.endswith(".kt"))
|
||||
glyphs, cmap, names, skipped = {".notdef": TTGlyphPen(None).glyph()}, {}, [".notdef"], []
|
||||
cp = FIRST_CODEPOINT
|
||||
for fn in files:
|
||||
name = fn[:-3]
|
||||
src = open(os.path.join(icons_dir, fn), encoding="utf-8").read()
|
||||
if is_stroked(src):
|
||||
skipped.append((name, "draws a stroke; a glyph can only be filled"))
|
||||
continue
|
||||
viewport, d = kotlin_to_svg_path(src)
|
||||
if not d:
|
||||
skipped.append((name, "no path data"))
|
||||
continue
|
||||
try:
|
||||
glyphs[name] = build_glyph(d, viewport)
|
||||
except Exception as e: # noqa: BLE001 - report and continue, don't kill the build
|
||||
skipped.append((name, f"{type(e).__name__}: {e}"))
|
||||
continue
|
||||
cmap[cp] = name
|
||||
names.append(name)
|
||||
print(f" {name:<12} U+{cp:04X} viewport {viewport[0]:g}x{viewport[1]:g} {len(d)} chars")
|
||||
cp += 1
|
||||
|
||||
fb = FontBuilder(UPEM, isTTF=True)
|
||||
fb.setupGlyphOrder(names)
|
||||
fb.setupCharacterMap(cmap)
|
||||
fb.setupGlyf(glyphs)
|
||||
fb.setupHorizontalMetrics({n: (ADVANCE, 0) for n in names})
|
||||
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
|
||||
fb.setupNameTable({
|
||||
"familyName": "Amethyst Icons", "styleName": "Regular",
|
||||
"psName": "AmethystIcons-Regular", "version": "1.0",
|
||||
})
|
||||
fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=DESCENT,
|
||||
usWinAscent=ASCENT, usWinDescent=abs(DESCENT))
|
||||
fb.setupPost(keepGlyphNames=False)
|
||||
fb.save(out_ttf)
|
||||
|
||||
with open(out_kt, "w", encoding="utf-8") as fh:
|
||||
fh.write("// GENERATED by tools/icon-font/build_icon_font.py -- do not edit by hand.\n")
|
||||
fh.write("package com.vitorpamplona.amethyst.commons.icons.symbols\n\n")
|
||||
fh.write("/** Amethyst's own icons as font glyphs. See the build script for why. */\n")
|
||||
fh.write("object AmethystIcons {\n")
|
||||
for code, name in sorted(cmap.items()):
|
||||
fh.write(f' val {name} = MaterialSymbol("\\u{code:04X}")\n')
|
||||
fh.write("}\n")
|
||||
|
||||
print(f"\nwrote {out_ttf} ({os.path.getsize(out_ttf)} bytes), {len(cmap)} glyphs")
|
||||
for name, why in skipped:
|
||||
print(f" SKIPPED {name}: {why}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 4:
|
||||
sys.exit(__doc__)
|
||||
main(*sys.argv[1:])
|
||||
Reference in New Issue
Block a user