diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e0a8c7604a..732f94a837 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2d7dc659d0..7606686a02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3546,6 +3546,15 @@ class Account( suspend fun saveBlockedRelayList(blockedRelays: List) = 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt index 8bc4cec59e..533d57a239 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockedRelays/BlockedRelayListState.kt @@ -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): BlockedRelayListEvent { if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException() val relayListForBlocked = getBlockedRelayList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index f442bc3ef0..25f5203281 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -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 + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt index e655dfc2b6..b92cb6eeb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt @@ -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, + ) + } + } + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt index c7e146b5bf..1e170f6171 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt index e42954b26c..1086d7a77a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt @@ -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 } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index 29feeb08aa..b01af54ff3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt index 8d0b0193f5..54719239ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt @@ -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, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt index be8d33b0e4..800a01c466 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt @@ -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(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, - ) - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt index b64b7bc3f0..812f56a2d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 68838e57f6..706f05cca7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -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)), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1c2b7e3e82..6ac5f887dd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3123,6 +3123,7 @@ Message from %1$s + Block Relay Thread Send the seller a message Hi %1$s, is this still available? diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt new file mode 100644 index 0000000000..43838456fc --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListAddRelayTest.kt @@ -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() + every { Looper.getMainLooper() } returns mockk() + } + + @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(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") } }, + ) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.kt new file mode 100644 index 0000000000..46dce6b5d6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCacheTest.kt @@ -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()) + } +} diff --git a/commons/src/commonMain/composeResources/font/amethyst_icons.ttf b/commons/src/commonMain/composeResources/font/amethyst_icons.ttf new file mode 100644 index 0000000000..5c7e7a716c Binary files /dev/null and b/commons/src/commonMain/composeResources/font/amethyst_icons.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt new file mode 100644 index 0000000000..07fc7f362d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt @@ -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") +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt new file mode 100644 index 0000000000..da2c0599fb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt @@ -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 = 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) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt index 64f7cfa296..4ff16e01cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt index 80e446bc51..32b7db05e7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt @@ -73,12 +73,25 @@ class BlockedRelayFilteringClient( relayList: Set, ) { 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>.withoutBlocked(): Map> { 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.hitsNoneOf(targets: Collection): Boolean = isEmpty() || targets.none { it in this } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt index 4965c0cc40..0fe19bde4f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt @@ -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() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index db0bd529f8..c3aa87d9c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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( diff --git a/tools/icon-font/README.md b/tools/icon-font/README.md new file mode 100644 index 0000000000..f669635728 --- /dev/null +++ b/tools/icon-font/README.md @@ -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 + +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`. diff --git a/tools/icon-font/build_icon_font.py b/tools/icon-font/build_icon_font.py new file mode 100755 index 0000000000..548eebb179 --- /dev/null +++ b/tools/icon-font/build_icon_font.py @@ -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 +""" +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:])