diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 06d0b541d1..7c55e0819b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -194,6 +194,64 @@ jobs: name: geode Test Reports path: geode/build/reports + # Until this job existed nothing ran the linuxX64 target at all — it was compiled by + # no CI leg. That is how a copy-on-write LargeCache with O(n) writes and a non-atomic + # read-copy-write (concurrent writers silently dropped entries) sat in the tree + # unnoticed, and how TestResourceLoader stayed a TODO() that failed every vector-driven + # suite on the target. + # + # Runs the whole :quartz suite on a Linux Native frontend, which also catches a + # commonMain or commonTest source reaching for a JVM-only API on a target that, unlike + # Apple, has no Foundation to fall back on. + test-quartz-linux-native: + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up JDK 21 + uses: actions/setup-java@v6.0.0 + with: + distribution: 'temurin' + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + # The Kotlin/Native toolchain (compiler distribution + LLVM + the sysroot) lands + # in ~/.konan, which setup-gradle does not cache. Without this the job re-downloads + # well over a gigabyte on every run. Keyed on the version catalog so a Kotlin bump + # re-populates it. + - name: Cache Kotlin/Native toolchain + uses: actions/cache@v4 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: konan-${{ runner.os }}- + + - name: Test Quartz on Linux Native + run: ./gradlew :quartz:linuxX64Test + + - name: Linux Native Test Report + uses: mikepenz/action-junit-report@a9170d5795813c01ab4901ffb045b52bab4ab09d # v6.5.0 + if: always() + with: + report_paths: 'quartz/build/test-results/linuxX64Test/TEST-*.xml' + annotate_only: true + detailed_summary: true + fail_on_failure: true + + - name: Upload Linux Native Test Reports + uses: actions/upload-artifact@v7 + if: failure() + with: + name: Quartz Linux Native Test Reports + path: quartz/build/reports + test-quartz-ios: # Phase 1 of the iOS support plan # (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt new file mode 100644 index 0000000000..12b6892a0f --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.animation.core.tween +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The feed's animated elements defer building their `Transition` until a value actually changes, + * because first composition has nothing to animate and building one per card per scroll is pure + * waste (measured: roughly half the composition cost of every reaction-row button). + * + * The whole point of deferring rather than removing is that the animation must still play. These + * tests pin that: they drive the clock manually and assert that the **first** change — the one that + * happens right after the transition is lazily created — still shows outgoing and incoming content + * simultaneously, which only a running animation does. A regression that turned the deferral into a + * plain snap would show exactly one of them and fail here. + */ +@RunWith(AndroidJUnit4::class) +class DeferredAnimationTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun deferredCrossfadeStillAnimatesTheFirstChange() { + val state = mutableStateOf("A") + rule.mainClock.autoAdvance = false + + rule.setContent { + DeferredCrossfade( + targetState = state.value, + modifier = Modifier, + contentAlignment = Alignment.TopStart, + animationSpec = tween(DURATION_MS), + label = "test", + ) { value -> + Text(value, modifier = Modifier.testTag("text_$value")) + } + } + + // Before any change the transition has not been built, and only the current value renders. + rule.onNodeWithTag("text_A").assertIsDisplayed() + rule.onNodeWithTag("text_B").assertDoesNotExist() + + state.value = "B" + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeBy(DURATION_MS / 3L) + + // Mid-crossfade both are in the tree. This is the assertion that a snap would fail. + rule.onNodeWithTag("text_A").assertExists() + rule.onNodeWithTag("text_B").assertExists() + + rule.mainClock.advanceTimeBy(DURATION_MS * 3L) + rule.onNodeWithTag("text_B").assertIsDisplayed() + rule.onNodeWithTag("text_A").assertDoesNotExist() + } + + companion object { + const val DURATION_MS = 300 + } +} 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 5426235d56..d0cedc5e06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3543,6 +3543,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 384f4d58ee..ae61bd1b1a 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 29e7aa9e00..84d5f3aaa8 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 a8eea13495..64be08b875 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,11 +30,13 @@ 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 import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -56,6 +58,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, @@ -95,5 +102,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 1ceee8415f..4d3d1722ba 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 @@ -102,8 +102,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/ui/actions/CrossfadeIfEnabled.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt index fc6be9550a..4c5014141f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt @@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.collection.mutableScatterMapOf import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.layout.Box @@ -54,7 +56,53 @@ fun CrossfadeIfEnabled( content(targetState) } } else { - MyCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content) + DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content) + } +} + +/** Latches the first time a crossfade's target moves off the value it was composed with. */ +private class ChangeLatch { + var changed = false +} + +/** + * A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there + * is something to animate. + * + * `updateTransition` allocates a transition, its animation list and its seeking state on *first + * composition*, even though first composition has nothing to cross-fade — target and initial state + * are the same value. In a feed that is waste: every card scrolled in builds a transition per + * animated element, and during a scroll essentially none of them run, because the underlying counts + * and icons do not change in the second a card is on screen. + * + * So the plain content renders until the target actually moves. At that point the transition is + * built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at + * the new one, so the first real change still animates exactly as before; every later change + * animates through the now-live transition normally. + */ +@OptIn(ExperimentalAnimationApi::class) +@Composable +internal fun DeferredCrossfade( + targetState: T, + modifier: Modifier, + contentAlignment: Alignment, + animationSpec: FiniteAnimationSpec, + label: String, + content: @Composable (T) -> Unit, +) { + val initial = remember { targetState } + val latch = remember { ChangeLatch() } + if (targetState != initial) latch.changed = true + + if (!latch.changed) { + Box(modifier, contentAlignment) { + content(targetState) + } + } else { + val transitionState = remember { MutableTransitionState(initial) } + transitionState.targetState = targetState + val transition = rememberTransition(transitionState, label) + transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt index e9e27deb53..4bda918632 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt @@ -45,9 +45,6 @@ private tailrec fun Context.getActivityWindow(): Window? = else -> null } -@Composable -fun getActivity(): Activity = LocalContext.current.getActivity() - tailrec fun Context.getActivity(): ComponentActivity = when (this) { is ComponentActivity -> this diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt index 57b66bc8b5..51ceb43c1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt @@ -26,8 +26,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.compositionLocalOf @@ -35,8 +35,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.components.getActivity /** How the app shell presents its top-level navigation for the current window size. */ enum class NavigationStyle { @@ -44,12 +44,13 @@ enum class NavigationStyle { BOTTOM_BAR, /** - * Medium windows (portrait tablets, unfolded foldables): a left navigation rail - * replaces the bottom bar; the drawer stays modal behind the rail's avatar button. + * Every non-Compact window that does not dock — portrait tablets and unfolded foldables at + * any width, plus short landscape windows: a left navigation rail replaces the bottom bar + * and the drawer stays modal behind the rail's avatar button. */ NAV_RAIL, - /** Expanded windows (landscape tablets, desktop windows): the drawer docks permanently on the left. */ + /** Wide, landscape, tall windows (landscape tablets, desktop): the drawer docks permanently on the left. */ PERMANENT_DRAWER, } @@ -60,7 +61,7 @@ enum class NavigationStyle { @Immutable data class ScreenLayoutSpec( val navigationStyle: NavigationStyle, - val showsNotificationPanel: Boolean, + val hasRoomForNotificationPanel: Boolean, ) { /** * True on the rail and permanent-drawer tiers. Large screens hide the bottom bar and pin @@ -69,16 +70,18 @@ data class ScreenLayoutSpec( val isLargeScreen: Boolean get() = navigationStyle != NavigationStyle.BOTTOM_BAR companion object { - val Phone = ScreenLayoutSpec(NavigationStyle.BOTTOM_BAR, showsNotificationPanel = false) + val Phone = ScreenLayoutSpec(NavigationStyle.BOTTOM_BAR, hasRoomForNotificationPanel = false) } } val LocalScreenLayout = compositionLocalOf { ScreenLayoutSpec.Phone } /** - * Minimum window width for the docked notification panel: the permanent drawer - * ([PermanentDrawerWidth]) + a readable center pane + the panel ([NotificationPanelWidth]) - * only coexist comfortably from a landscape-tablet-sized window up. + * Minimum window width for the docked notification panel: a leading navigation pane, a + * readable center pane and the panel ([NotificationPanelWidth]) only coexist comfortably from + * a landscape-tablet-sized window up. Sized against the widest leading pane, the permanent + * drawer ([PermanentDrawerWidth]); the rail is narrower, so a railed window that clears this + * gets a roomier center pane rather than a tighter one. */ private const val NOTIFICATION_PANEL_MIN_WINDOW_DP = 1200 @@ -95,6 +98,53 @@ val NotificationPanelWidth = 360.dp */ val FeedContentMaxWidth = 600.dp +/** + * Minimum window height for the docked drawer. Higher than Material's 480dp Compact/Medium + * height boundary on purpose: the permanent drawer's own header — banner, avatar, status + * editor, follower counts — fills most of a ~540dp column before the first navigation row, so + * below this the rail shows more of the menu than the dock does. + */ +private const val DOCK_MIN_WINDOW_HEIGHT_DP = 600 + +/** + * The navigation tier for a window of this shape. + * + * The dock is not a width decision. A tablet is past the Expanded breakpoint in both + * orientations, so keying on width alone pins 300dp of menu open in portrait with no closed + * state to fall back on (issue #4024). It docks only when the window is wide, landscape, and + * tall enough for the drawer's own content to be usable; everything else that is not Compact + * falls through to the rail, which pairs with the existing swipe-in modal drawer. + * + * A square window counts as landscape and docks; `Configuration.ORIENTATION_LANDSCAPE` + * breaks that tie the other way, so the two disagree at exactly width == height. + */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +internal fun decideNavigationStyle( + windowWidthDp: Int, + windowHeightDp: Int, +): NavigationStyle { + val widthSizeClass = + WindowSizeClass + .calculateFromSize(DpSize(windowWidthDp.dp, windowHeightDp.dp)) + .widthSizeClass + + return when { + widthSizeClass == WindowWidthSizeClass.Expanded && + windowWidthDp >= windowHeightDp && + windowHeightDp >= DOCK_MIN_WINDOW_HEIGHT_DP -> NavigationStyle.PERMANENT_DRAWER + widthSizeClass != WindowWidthSizeClass.Compact -> NavigationStyle.NAV_RAIL + else -> NavigationStyle.BOTTOM_BAR + } +} + +/** + * Whether the window is wide enough to dock the notification feed beside the content. + * + * Deliberately not keyed on [NavigationStyle]: a wide portrait window now gets the rail, and + * gating on the dock would strip a panel it has today. + */ +internal fun hasRoomForNotificationPanel(windowWidthDp: Int): Boolean = windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP + /** * Centers a destination's content at [FeedContentMaxWidth]. The outer box paints the theme * background so the gutters match the screens' own surfaces; on Compact windows the cap is @@ -119,23 +169,15 @@ fun CappedScreenContent(content: @Composable () -> Unit) { } } -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun rememberScreenLayoutSpec(): ScreenLayoutSpec { - val widthSizeClass = calculateWindowSizeClass(getActivity()).widthSizeClass - val windowWidthDp = LocalConfiguration.current.screenWidthDp - return remember(widthSizeClass, windowWidthDp) { - val style = - when (widthSizeClass) { - WindowWidthSizeClass.Expanded -> NavigationStyle.PERMANENT_DRAWER - WindowWidthSizeClass.Medium -> NavigationStyle.NAV_RAIL - else -> NavigationStyle.BOTTOM_BAR - } + val configuration = LocalConfiguration.current + val windowWidthDp = configuration.screenWidthDp + val windowHeightDp = configuration.screenHeightDp + return remember(windowWidthDp, windowHeightDp) { ScreenLayoutSpec( - navigationStyle = style, - showsNotificationPanel = - style == NavigationStyle.PERMANENT_DRAWER && - windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP, + navigationStyle = decideNavigationStyle(windowWidthDp, windowHeightDp), + hasRoomForNotificationPanel = hasRoomForNotificationPanel(windowWidthDp), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index af1b72de82..7249860db1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -31,6 +31,7 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn @@ -864,11 +865,7 @@ private fun SlidingAnimationCount( if (accountViewModel.settings.isPerformanceMode()) { TextCount(baseCount, textColor) } else { - AnimatedContent( - targetState = baseCount, - transitionSpec = AnimatedContentTransitionScope::transitionSpec, - label = "SlidingAnimationCount", - ) { count -> + DeferredAnimatedContent(baseCount, "SlidingAnimationCount") { count -> TextCount(count, textColor) } } @@ -891,6 +888,48 @@ val slideAnimation: ContentTransform = ), ) +/** Latches the first time an animated counter's value moves off the one it was composed with. */ +private class CountChangeLatch { + var changed = false +} + +/** + * An [AnimatedContent] that does not build its transition until the value actually changes. + * + * Same reasoning as `DeferredCrossfade`: `AnimatedContent` builds a transition plus its content map + * and size animation on first composition, but first composition has nothing to animate. A reaction + * counter only slides when the count moves, which practically never happens in the second a card + * spends on screen during a scroll — so the apparatus was built and thrown away, once per counter + * per card. + * + * Rendering the bare content until the first change, then seeding a [MutableTransitionState] at the + * original value, keeps that first change animated exactly as before. + */ +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun DeferredAnimatedContent( + targetState: T, + label: String, + content: @Composable (T) -> Unit, +) { + val initial = remember { targetState } + val latch = remember { CountChangeLatch() } + if (targetState != initial) latch.changed = true + + if (!latch.changed) { + content(targetState) + } else { + val transitionState = remember { MutableTransitionState(initial) } + transitionState.targetState = targetState + val transition = rememberTransition(transitionState, label) + transition.AnimatedContent( + transitionSpec = { transitionSpec() }, + ) { value -> + content(value) + } + } +} + @Composable fun TextCount( count: Int, @@ -918,11 +957,7 @@ fun SlidingAnimationAmount( maxLines = 1, ) } else { - AnimatedContent( - targetState = amount, - transitionSpec = AnimatedContentTransitionScope::transitionSpec, - label = "SlidingAnimationAmount", - ) { count -> + DeferredAnimatedContent(amount, "SlidingAnimationAmount") { count -> Text( text = count, fontSize = Font14SP, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index dbeddda760..1008e74dcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -138,8 +138,10 @@ fun AccountSwitcherAndLeftDrawerLayout( } /** - * Compact and Medium windows: the drawer slides in as a modal sheet. On Medium an - * [AppNavigationRail] sits at the left edge in place of the phone bottom bar. + * Every window that does not dock the drawer: the drawer slides in as a modal sheet. On the + * rail tier an [AppNavigationRail] sits at the left edge in place of the phone bottom bar and + * the shell becomes multi-pane — a wide portrait window rails and is still wide enough for + * the notification panel. */ @Composable private fun ModalDrawerShell( @@ -183,11 +185,12 @@ private fun ModalDrawerShell( }, content = { if (showRail) { - Row(Modifier.fillMaxSize()) { - AppNavigationRail(nav, accountViewModel) - VerticalDivider(thickness = DividerThickness) - CenterPane(Modifier.weight(1f), content) - } + MultiPaneShell( + accountViewModel = accountViewModel, + nav = nav, + leading = { AppNavigationRail(nav, accountViewModel) }, + content = content, + ) } else { content() } @@ -196,8 +199,62 @@ private fun ModalDrawerShell( } /** - * Expanded windows: the drawer is permanently docked on the left, the bottom bar disappears, - * and — when the window is wide enough — the notification feed docks on the right. + * The wide-window arrangement both shells render: a [leading] navigation pane, the centre + * content, and the notification feed when the window has room. Shared because a wide portrait + * window now rails rather than docks and is still wide enough for the panel — the two shells + * differ only in which navigation pane leads. + */ +@Composable +private fun MultiPaneShell( + accountViewModel: AccountViewModel, + nav: Nav, + leading: @Composable () -> Unit, + content: @Composable () -> Unit, +) { + Row(Modifier.fillMaxSize()) { + leading() + + VerticalDivider(thickness = DividerThickness) + + CenterPane(Modifier.weight(1f), content) + + NotificationSidePanelSlot(accountViewModel, nav) + } +} + +/** + * The docked notification feed, when there is room for it. The panel duplicates the + * Notifications screen, so it steps aside while the user is there. + * + * The back-stack entry is collected here rather than in the shells so windows too narrow for + * the panel never observe it, and so navigation churn recomposes this slot instead of the + * whole shell. The trade is that a rail-tier window wide enough for the panel ends up with a + * second collector beside [ModalDrawerShell]'s own — one extra subscriber on a shared flow, + * against a shell restart per navigation on every window that cannot show the panel. + * + * [Route] matching goes through `remember` because `hasRoute` resolves a serializer + * reflectively on every call. + */ +@Composable +private fun NotificationSidePanelSlot( + accountViewModel: AccountViewModel, + nav: Nav, +) { + if (!LocalScreenLayout.current.hasRoomForNotificationPanel) return + + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + val destination = navBackStackEntry?.destination + val onNotifications = remember(destination) { destination?.hasRoute() == true } + + if (!onNotifications) { + VerticalDivider(thickness = DividerThickness) + NotificationSidePanel(accountViewModel, nav) + } +} + +/** + * Wide, landscape, tall windows: the drawer is permanently docked on the left, the bottom bar + * disappears, and — when the window is wide enough — the notification feed docks on the right. */ @Composable private fun PermanentDrawerShell( @@ -206,24 +263,12 @@ private fun PermanentDrawerShell( openSheet: () -> Unit, content: @Composable () -> Unit, ) { - val navBackStackEntry by nav.controller.currentBackStackEntryAsState() - // The panel duplicates the Notifications screen, so it steps aside while the user is there. - val showPanel = - LocalScreenLayout.current.showsNotificationPanel && - navBackStackEntry?.destination?.hasRoute() != true - - Row(Modifier.fillMaxSize()) { - PermanentDrawerContent(nav, openSheet, accountViewModel) - - VerticalDivider(thickness = DividerThickness) - - CenterPane(Modifier.weight(1f), content) - - if (showPanel) { - VerticalDivider(thickness = DividerThickness) - NotificationSidePanel(accountViewModel, nav) - } - } + MultiPaneShell( + accountViewModel = accountViewModel, + nav = nav, + leading = { PermanentDrawerContent(nav, openSheet, accountViewModel) }, + content = content, + ) } /** 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 17c5724805..40b6b263f5 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.commons.model.User import com.vitorpamplona.amethyst.model.LocalCache -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 95f7a279f2..c35e73e5c4 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 @@ -75,7 +74,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 @@ -156,6 +154,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) } @@ -191,15 +190,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) } } }, ) @@ -235,19 +232,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) { @@ -266,14 +257,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 e70d468ce6..eeda4bccf3 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 @@ -295,6 +295,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 3c74b51914..68838e57f6 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 @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.theme import android.app.Activity -import android.app.UiModeManager -import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme @@ -49,7 +47,6 @@ import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.SpanStyle @@ -712,24 +709,15 @@ fun AmethystTheme( fontSize: FontSizeType = FontSizeType.NORMAL, content: @Composable () -> Unit, ) { - val context = LocalContext.current + // Deliberately no UiModeManager.nightMode write: changing the device night mode needs + // MODIFY_DAY_NIGHT_MODE, which this app does not declare, so the call silently no-ops — and it + // ran on every recomposition of the theme, writing device state from inside composition. The + // in-app choice is applied through the colour scheme below, which is what actually took effect. val darkTheme = when (prefTheme) { - ThemeType.DARK -> { - val uiManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager - uiManager.nightMode = UiModeManager.MODE_NIGHT_YES - true - } - - ThemeType.LIGHT -> { - val uiManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager - uiManager.nightMode = UiModeManager.MODE_NIGHT_NO - false - } - - else -> { - isSystemInDarkTheme() - } + ThemeType.DARK -> true + ThemeType.LIGHT -> false + else -> isSystemInDarkTheme() } val colors = remember(darkTheme, accentColor) { diff --git a/amethyst/src/main/res/values-night/themes.xml b/amethyst/src/main/res/values-night/themes.xml index 909b3e0a03..d436bd7ea1 100644 --- a/amethyst/src/main/res/values-night/themes.xml +++ b/amethyst/src/main/res/values-night/themes.xml @@ -2,6 +2,13 @@ diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 17161a4c45..1cf6382cf4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1732,6 +1732,7 @@ Message from %1$s + Block Relay Hi %1$s, is this still available? diff --git a/amethyst/src/main/res/values/themes.xml b/amethyst/src/main/res/values/themes.xml index 0935ce7a7b..828ada16d5 100644 --- a/amethyst/src/main/res/values/themes.xml +++ b/amethyst/src/main/res/values/themes.xml @@ -2,6 +2,13 @@