mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c49b47f99b | ||
|
|
3845c52853 | ||
|
|
6b2df0fb5b | ||
|
|
2243de5d2d | ||
|
|
a50172bb38 | ||
|
|
86e0824581 | ||
|
|
cac8fb9e19 | ||
|
|
b7ae068141 | ||
|
|
8f75cf08f8 |
@@ -88,6 +88,156 @@ android {
|
||||
buildConfigField("String", "RELEASE_NOTES_ID", "\"00d306e01792e48b93638b73b57a7eb8b89622a338b1b4f529622150d46cd710\"")
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Measurement probe: see service/relayClient/reqCommand/ProbedCollect.kt. Off unless
|
||||
// -PprobeNoFlowState=true is passed, so normal builds constant-fold it away.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_NO_FLOW_STATE",
|
||||
(project.findProperty("probeNoFlowState")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Measurement probe: forces the reaction row's Crossfade / AnimatedContent wrappers
|
||||
// onto their existing non-animated branch, so the cost of building the transition
|
||||
// machinery per card can be measured. Off unless -PprobeNoRxAnimations=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_NO_RX_ANIMATIONS",
|
||||
(project.findProperty("probeNoRxAnimations")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Measurement probe: strips the reaction row's ClickableBox down to a plain Box, so the
|
||||
// cost of `clickable` + its eagerly-created MutableInteractionSource and ripple node can
|
||||
// be bounded. Off unless -PprobeNoRxClickable=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_NO_RX_CLICKABLE",
|
||||
(project.findProperty("probeNoRxClickable")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Candidate fix under measurement: keeps the reaction row's click, ripple and semantics
|
||||
// but lets Compose build the interaction source and ripple node lazily on first touch.
|
||||
// Off unless -PprobeLazyRxRipple=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_LAZY_RX_RIPPLE",
|
||||
(project.findProperty("probeLazyRxRipple")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Candidate fix under measurement: defers building Crossfade/AnimatedContent transitions
|
||||
// until a value actually changes, so scrolling a card in costs nothing for animations
|
||||
// that never play. Keeps the animations. -PfixLazyAnim=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"FIX_LAZY_ANIM",
|
||||
(project.findProperty("fixLazyAnim")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Measurement probe: replaces the reaction row's counter Text with a same-width Spacer,
|
||||
// isolating the cost of text shaping from the rest of the button. Off unless
|
||||
// -PprobeNoRxCounters=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_NO_RX_COUNTERS",
|
||||
(project.findProperty("probeNoRxCounters")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Measurement probe: replaces the reaction row's produceState calls with a plain snapshot
|
||||
// state holding the same initial value, so the coroutine each one launches per card is
|
||||
// never started. Off unless -PprobeNoRxProduceState=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_NO_RX_PRODUCE_STATE",
|
||||
(project.findProperty("probeNoRxProduceState")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Measurement probe: replaces the reaction row's icons with same-size Spacers, isolating
|
||||
// vector-path and glyph rasterisation from the rest of the row's draw cost.
|
||||
// Off unless -PprobeNoRxIcons=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_NO_RX_ICONS",
|
||||
(project.findProperty("probeNoRxIcons")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Measurement probe: draws the three reaction icons from Amethyst's OWN artwork
|
||||
// converted to a font by tools/icon-font/build_icon_font.py. Same pixels as today,
|
||||
// but blitted from the text atlas instead of rasterised per card.
|
||||
// -PprobeRxCustomFont=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_RX_CUSTOM_FONT",
|
||||
(project.findProperty("probeRxCustomFont")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Measurement probe: draws the three reaction icons (Like/Reply/Reposted) as
|
||||
// MaterialSymbols font glyphs instead of ImageVector paths, to price the conversion.
|
||||
// The ablation says vector rasterisation is the reaction row's cost (frame P90 -12.7%
|
||||
// for 3 icons) while the whole glyph set costs -1.6%; this measures what is actually
|
||||
// recovered by swapping, since a glyph is not free. Uses the closest available glyphs
|
||||
// (Chat / Sync / Favorite) -- appearance differs, so this prices the change, it does
|
||||
// not propose it. -PprobeRxGlyphSwap=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_RX_GLYPH_SWAP",
|
||||
(project.findProperty("probeRxGlyphSwap")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Candidate fix under measurement: shares one VectorPainter per reaction icon across every
|
||||
// card in the feed instead of one per card. -PfixSharedIcons=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"FIX_SHARED_ICONS",
|
||||
(project.findProperty("fixSharedIcons")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Splits the icon ablation: which half of the reaction row's icon draw cost is vector-path
|
||||
// rasterisation (Like/Reply/Reposted) and which is MaterialSymbols font glyphs (Bolt,
|
||||
// Share, ExpandLess/More, Mic). -PprobeNoRxVectorIcons / -PprobeNoRxGlyphIcons.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_NO_RX_VECTOR_ICONS",
|
||||
(project.findProperty("probeNoRxVectorIcons")?.toString() ?: "false"),
|
||||
)
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_NO_RX_GLYPH_ICONS",
|
||||
(project.findProperty("probeNoRxGlyphIcons")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Measurement probe: bakes the circle into the decoded bitmap with Coil's
|
||||
// CircleCropTransformation and drops the per-frame outline clip. This is the
|
||||
// *shippable* form of the PROBE_NO_AVATAR_CLIP win (frame P90 -6.2%): the circle
|
||||
// is applied once at decode and cached, rather than clipped on every frame.
|
||||
// Risk being measured: Coil cannot transform hardware bitmaps, so this may force
|
||||
// software bitmaps and cost more in drawing than the clip saved.
|
||||
// -PprobeCircleCrop=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_CIRCLE_CROP",
|
||||
(project.findProperty("probeCircleCrop")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Measurement probe: swaps the feed avatar's SubcomposeAsyncImage for a plain AsyncImage.
|
||||
// Coil documents SubcomposeAsyncImage as the slower option, but MEASURED IT IS NOT: the
|
||||
// swap is a regression, DrawAuthor +74% per occurrence against a 4.6% drift floor, because
|
||||
// AsyncImagePainter keeps the placeholder/error/fallback painters live and draws through
|
||||
// more indirection than the resolved Image subcomposition settles into. Kept so the
|
||||
// result can be re-verified; do not "fix" the feed by taking the plain path.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_NO_SUBCOMPOSE_AVATAR",
|
||||
(project.findProperty("probeNoSubcomposeAvatar")?.toString() ?: "false"),
|
||||
)
|
||||
|
||||
// Measurement probe: drops the circular clip from feed avatars, so the RenderThread cost of
|
||||
// the per-avatar graphics layer + outline clip can be separated from decoding and drawing
|
||||
// the image itself. Probe builds show square avatars. -PprobeNoAvatarClip=true.
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"PROBE_NO_AVATAR_CLIP",
|
||||
(project.findProperty("probeNoAvatarClip")?.toString() ?: "false"),
|
||||
)
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
@@ -210,11 +360,15 @@ android {
|
||||
getByName("release") {
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
isMinifyEnabled = !skipMapping
|
||||
// See TRACE_NOTE_RENDER on the benchmark type below. Constant-false here, so
|
||||
// R8 deletes every feed trace marker from the shipped build.
|
||||
buildConfigField("boolean", "TRACE_NOTE_RENDER", "false")
|
||||
}
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-DEBUG"
|
||||
resValue("string", "app_name", "@string/app_name_debug")
|
||||
buildConfigField("boolean", "TRACE_NOTE_RENDER", "false")
|
||||
}
|
||||
create("benchmark") {
|
||||
initWith(getByName("release"))
|
||||
@@ -223,6 +377,10 @@ android {
|
||||
resValue("string", "app_name", "@string/app_name_benchmark")
|
||||
isProfileable = true
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Emits atrace sections around each part of the feed's note card, so
|
||||
// :macrobenchmark's TraceSectionMetric can attribute composition time
|
||||
// per sub-component. Benchmark-only: see ui/note/NoteRenderTrace.kt.
|
||||
buildConfigField("boolean", "TRACE_NOTE_RENDER", "true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +553,10 @@ dependencies {
|
||||
// Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source:
|
||||
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
|
||||
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
|
||||
// atrace sections for the feed's note card (ui/note/NoteRenderTrace.kt). Compiled in
|
||||
// everywhere, but constant-folded out of debug/release by BuildConfig.TRACE_NOTE_RENDER.
|
||||
implementation(libs.androidx.tracing)
|
||||
|
||||
debugImplementation("androidx.compose.runtime:runtime-tracing")
|
||||
debugImplementation("androidx.tracing:tracing-perfetto:1.0.1")
|
||||
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.1")
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deferredAnimatedContentStillAnimatesTheFirstChange() {
|
||||
val count = mutableStateOf(1)
|
||||
rule.mainClock.autoAdvance = false
|
||||
|
||||
rule.setContent {
|
||||
DeferredAnimatedContent(targetState = count.value, label = "test") { value ->
|
||||
Text("$value", modifier = Modifier.testTag("count_$value"))
|
||||
}
|
||||
}
|
||||
|
||||
rule.onNodeWithTag("count_1").assertIsDisplayed()
|
||||
rule.onNodeWithTag("count_2").assertDoesNotExist()
|
||||
|
||||
count.value = 2
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.mainClock.advanceTimeBy(SLIDE_MS / 3L)
|
||||
|
||||
// The sliding counter keeps the outgoing number on screen while the new one slides in.
|
||||
rule.onNodeWithTag("count_1").assertExists()
|
||||
rule.onNodeWithTag("count_2").assertExists()
|
||||
|
||||
rule.mainClock.advanceTimeBy(SLIDE_MS * 5L)
|
||||
rule.onNodeWithTag("count_2").assertIsDisplayed()
|
||||
rule.onNodeWithTag("count_1").assertDoesNotExist()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DURATION_MS = 300
|
||||
|
||||
/** `slideAnimation` in ReactionsRow uses a 100 ms tween. */
|
||||
const val SLIDE_MS = 100
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.reqCommand
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* **Measurement probe — not a feature.**
|
||||
*
|
||||
* A feed card subscribes to dozens of `LocalCache` flows, and every subscription costs a
|
||||
* `collectAsStateWithLifecycle`: a `LifecycleEventObserver` allocated and registered, plus a
|
||||
* coroutine launched to run `repeatOnLifecycle`. With ~30 of those per note and ~13 new notes
|
||||
* composed per scroll, the question is how much of the card's composition time is the
|
||||
* *subscription machinery* rather than the UI it feeds.
|
||||
*
|
||||
* When `PROBE_NO_FLOW_STATE` is on, these return a plain snapshot state holding exactly the
|
||||
* value the real collector would have shown at first composition — so the card renders the
|
||||
* same pixels on the way in — and simply never subscribe. The delta between a probe build and
|
||||
* a normal one is the cost of flow→state creation.
|
||||
*
|
||||
* A probe build is **broken on purpose**: nothing live updates any more (no incoming reaction,
|
||||
* zap, boost or reply count ever moves). It exists to be measured and thrown away. The flag
|
||||
* defaults to `false`, so ordinary builds are byte-for-byte unaffected — the constant folds and
|
||||
* R8 drops the branch.
|
||||
*
|
||||
* Enable with:
|
||||
* ```
|
||||
* ./gradlew … -PprobeNoFlowState=true
|
||||
* ```
|
||||
*/
|
||||
@Composable
|
||||
fun <T> StateFlow<T>.collectAsStateProbed(): State<T> =
|
||||
if (BuildConfig.PROBE_NO_FLOW_STATE) {
|
||||
remember(this) { mutableStateOf(value) }
|
||||
} else {
|
||||
collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> Flow<T>.collectAsStateProbed(initial: T): State<T> =
|
||||
if (BuildConfig.PROBE_NO_FLOW_STATE) {
|
||||
remember(this) { mutableStateOf(initial) }
|
||||
} else {
|
||||
collectAsStateWithLifecycle(initial)
|
||||
}
|
||||
+19
-19
@@ -24,11 +24,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.textNoteModifications
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.collectAsStateProbed
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -56,7 +56,7 @@ fun observeNote(
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow = remember(note) { note.flow().metadata.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
return flow.collectAsStateProbed()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -78,7 +78,7 @@ fun <T : Event> observeNoteEvent(
|
||||
.mapLatest { it.note.event as? T? }
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(note.event as? T?)
|
||||
return flow.collectAsStateProbed(note.event as? T?)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -102,7 +102,7 @@ fun <T> observeNoteAndMap(
|
||||
}
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return flow.collectAsStateWithLifecycle(map(note))
|
||||
return flow.collectAsStateProbed(map(note))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -128,7 +128,7 @@ fun <T, U> observeNoteEventAndMapNotNull(
|
||||
}
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return flow.collectAsStateWithLifecycle((note.event as? T)?.let { map(it) })
|
||||
return flow.collectAsStateProbed((note.event as? T)?.let { map(it) })
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -154,7 +154,7 @@ fun <T, U> observeNoteEventAndMap(
|
||||
}
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return flow.collectAsStateWithLifecycle(map(note.event as? T))
|
||||
return flow.collectAsStateProbed(map(note.event as? T))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -176,7 +176,7 @@ fun observeNoteHasEvent(
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(note.event != null)
|
||||
return flow.collectAsStateProbed(note.event != null)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -189,7 +189,7 @@ fun observeNoteReplies(
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow = remember(note) { note.flow().replies.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
return flow.collectAsStateProbed()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -212,7 +212,7 @@ fun observeNoteReplyCount(
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(note.replies.size)
|
||||
return flow.collectAsStateProbed(note.replies.size)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,7 +244,7 @@ fun observeNoteMinichatReplyCount(
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(note.replies.count { isMinichatReply(it.event) })
|
||||
return flow.collectAsStateProbed(note.replies.count { isMinichatReply(it.event) })
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -257,7 +257,7 @@ fun observeNoteReactions(
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow = remember(note) { note.flow().reactions.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
return flow.collectAsStateProbed()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -282,7 +282,7 @@ fun observeNoteReactionCount(
|
||||
}
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return flow.collectAsStateWithLifecycle(note.countReactions())
|
||||
return flow.collectAsStateProbed(note.countReactions())
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -295,7 +295,7 @@ fun observeNoteZaps(
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow = remember(note) { note.flow().zaps.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
return flow.collectAsStateProbed()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -308,7 +308,7 @@ fun observeNoteReposts(
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow = remember(note) { note.flow().boosts.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
return flow.collectAsStateProbed()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -332,7 +332,7 @@ fun observeNoteRepostsBy(
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(note.isBoostedBy(user))
|
||||
return flow.collectAsStateProbed(note.isBoostedBy(user))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -355,7 +355,7 @@ fun observeNoteRepostCount(
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(note.boosts.size)
|
||||
return flow.collectAsStateProbed(note.boosts.size)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -381,7 +381,7 @@ fun observeNoteReferences(
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(note.hasZapsBoostsOrReactions())
|
||||
return flow.collectAsStateProbed(note.hasZapsBoostsOrReactions())
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -394,7 +394,7 @@ fun observeNoteOts(
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow = remember(note) { note.flow().ots.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
return flow.collectAsStateProbed()
|
||||
}
|
||||
|
||||
// Resolves the actual modification list off the main thread and filters identical results,
|
||||
@@ -468,5 +468,5 @@ fun observeCommunityApprovalNeedStatus(
|
||||
}
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return flow.collectAsStateWithLifecycle(false)
|
||||
return flow.collectAsStateProbed(false)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -37,6 +39,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
@@ -53,11 +56,58 @@ fun <T> CrossfadeIfEnabled(
|
||||
Box(modifier, contentAlignment) {
|
||||
content(targetState)
|
||||
}
|
||||
} else if (BuildConfig.FIX_LAZY_ANIM) {
|
||||
DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
|
||||
} else {
|
||||
MyCrossfade(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 [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 pure waste: every card scrolled in builds a transition per
|
||||
* animated element, and during a scroll essentially none of them ever run, because the underlying
|
||||
* counts and icons do not change in the second the card is on screen.
|
||||
*
|
||||
* So the plain content is rendered 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 <T> DeferredCrossfade(
|
||||
targetState: T,
|
||||
modifier: Modifier,
|
||||
contentAlignment: Alignment,
|
||||
animationSpec: FiniteAnimationSpec<Float>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun <T> MyCrossfade(
|
||||
|
||||
@@ -115,3 +115,54 @@ fun ToggleableBox(
|
||||
content(isActive)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [ClickableBox], but lets Compose create the [MutableInteractionSource] and the ripple
|
||||
* node lazily, on the first touch, instead of eagerly at composition.
|
||||
*
|
||||
* `Modifier.clickable` only takes its lazy path when `interactionSource` is null and `indication`
|
||||
* is an `IndicationNodeFactory` (which `ripple()` is). Passing a remembered interaction source —
|
||||
* as [ClickableBox] does — forces both to be built up front, for every button, on every card the
|
||||
* feed scrolls in, even though the overwhelming majority are never touched.
|
||||
*
|
||||
* Behaviour is unchanged: same click, same ripple, same `Role.Button` semantics.
|
||||
*/
|
||||
@Composable
|
||||
fun ClickableBoxLazyRipple(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier.clickable(
|
||||
role = Role.Button,
|
||||
interactionSource = null,
|
||||
indication = ripple24dp,
|
||||
onClick = onClick,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ClickableBoxLazyRipple(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier.combinedClickable(
|
||||
role = Role.Button,
|
||||
interactionSource = null,
|
||||
indication = ripple24dp,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
+46
-1
@@ -41,10 +41,16 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.asDrawable
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transformations
|
||||
import coil3.transform.CircleCropTransformation
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
|
||||
import com.vitorpamplona.amethyst.commons.richtext.bridgeProfilePictureUrl
|
||||
@@ -148,6 +154,30 @@ fun RobohashFallbackAsyncImage(
|
||||
}
|
||||
|
||||
val resources = LocalContext.current.resources
|
||||
if (BuildConfig.PROBE_NO_SUBCOMPOSE_AVATAR) {
|
||||
// Probe: same pixels for a loaded image, and the same fallback painter while loading or
|
||||
// on error — but no subcomposition. The Animatable start/stop below is not needed here:
|
||||
// animated URLs are routed to GifProfilePicture by the branch above.
|
||||
AsyncImage(
|
||||
model =
|
||||
if (bridgedModel.startsWith("http://", ignoreCase = true) || bridgedModel.startsWith("https://", ignoreCase = true)) {
|
||||
ProfilePictureUrl(bridgedModel)
|
||||
} else {
|
||||
bridgedModel
|
||||
},
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality,
|
||||
placeholder = fallbackPainter,
|
||||
error = fallbackPainter,
|
||||
fallback = fallbackPainter,
|
||||
)
|
||||
return
|
||||
}
|
||||
SubcomposeAsyncImage(
|
||||
// The thumbnail-cache fetcher behind ProfilePictureUrl delegates to Coil's http-only
|
||||
// NetworkFetcher, so a LOCAL model (e.g. a decrypted Concord community icon cached at
|
||||
@@ -237,9 +267,24 @@ fun GifProfilePicture(
|
||||
)
|
||||
}
|
||||
|
||||
// Probe: bake the circle into the bitmap once instead of clipping every frame.
|
||||
val platformContext = LocalPlatformContext.current
|
||||
val avatarModel: Any? =
|
||||
if (BuildConfig.PROBE_CIRCLE_CROP && userPicture != null) {
|
||||
remember(userPicture) {
|
||||
ImageRequest
|
||||
.Builder(platformContext)
|
||||
.data(userPicture)
|
||||
.transformations(CircleCropTransformation())
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
userPicture
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
SubcomposeAsyncImage(
|
||||
model = userPicture,
|
||||
model = avatarModel,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
||||
@@ -21,20 +21,22 @@
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.LocalFeedReactionPainters
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.rememberFeedReactionPainters
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
@@ -51,25 +53,34 @@ fun FeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
if (header != null) {
|
||||
item {
|
||||
header()
|
||||
}
|
||||
}
|
||||
// One VectorPainter per reaction icon for the whole list rather than one per card. Provided
|
||||
// here, around the feed only, because a painter caches its raster by draw size and these icons
|
||||
// appear at other sizes on other screens. See FeedReactionPainters.
|
||||
val reactionPainters = rememberFeedReactionPainters()
|
||||
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, item -> item.event?.kind ?: -1 },
|
||||
) { _, item ->
|
||||
Row(Modifier.fillMaxWidth().animateItem()) {
|
||||
CompositionLocalProvider(LocalFeedReactionPainters provides reactionPainters) {
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
if (header != null) {
|
||||
item {
|
||||
header()
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, item -> item.event?.kind ?: -1 },
|
||||
) { _, item ->
|
||||
// No wrapper Row: it held nothing but a single child that already fills the width,
|
||||
// and NoteCompose puts this modifier straight onto NoteComposeLayout (or onto
|
||||
// BlankNote while the event loads, which needs the fillMaxWidth to keep its width).
|
||||
// The wrapper cost one layout node plus one fill constraint pass per feed item.
|
||||
NoteCompose(
|
||||
item,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().animateItem(),
|
||||
routeForLastRead = routeForLastRead,
|
||||
isBoostedNote = false,
|
||||
isHiddenFeed = items.showHidden,
|
||||
@@ -77,11 +88,11 @@ fun FeedLoaded(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.vector.VectorPainter
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import com.vitorpamplona.amethyst.commons.icons.Like
|
||||
import com.vitorpamplona.amethyst.commons.icons.Reply
|
||||
import com.vitorpamplona.amethyst.commons.icons.Reposted
|
||||
|
||||
/**
|
||||
* Vector painters for the three [ImageVector][androidx.compose.ui.graphics.vector.ImageVector]
|
||||
* icons a feed note's reaction row draws, created once and shared by every card on screen.
|
||||
*
|
||||
* `Icon(imageVector = …)` calls `rememberVectorPainter` internally, so each call site builds its
|
||||
* own [VectorPainter], and a painter rasterises its vector into a cached graphics layer **per
|
||||
* instance**. A feed therefore re-rasterised the same three glyphs once for every card scrolled in
|
||||
* — measured at roughly 1.8 ms of draw per card, the single largest draw cost in the note.
|
||||
*
|
||||
* Sharing is safe here because `Icon` applies `tint` as a draw-time `ColorFilter` rather than
|
||||
* baking it into the painter, so one painter serves the tinted and untinted states alike.
|
||||
*
|
||||
* **Scoped deliberately to the feed row.** A [VectorPainter] caches its raster by draw size, so the
|
||||
* same instance drawn at two sizes in one frame would re-rasterise on every draw and end up slower
|
||||
* than not sharing at all. These icons appear at 18/19/20/28 dp in different screens
|
||||
* (`UserReactionsRow`, `MultiSetCompose`, the reaction gallery); this local is provided only around
|
||||
* the feed, where each icon has exactly one size, so those other call sites keep their own painters
|
||||
* and cannot thrash this cache.
|
||||
*/
|
||||
@Immutable
|
||||
class FeedReactionPainters(
|
||||
val reply: VectorPainter,
|
||||
val reposted: VectorPainter,
|
||||
val like: VectorPainter,
|
||||
)
|
||||
|
||||
/** Null when no feed provided painters — call sites then fall back to their own, as before. */
|
||||
val LocalFeedReactionPainters = staticCompositionLocalOf<FeedReactionPainters?> { null }
|
||||
|
||||
@Composable
|
||||
fun rememberFeedReactionPainters(): FeedReactionPainters =
|
||||
FeedReactionPainters(
|
||||
reply = rememberVectorPainter(Reply),
|
||||
reposted = rememberVectorPainter(Reposted),
|
||||
like = rememberVectorPainter(Like),
|
||||
)
|
||||
@@ -388,37 +388,39 @@ fun NoteCompose(
|
||||
onClick: (() -> Unit)? = null,
|
||||
moreOptions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
WatchNoteEvent(
|
||||
baseNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav,
|
||||
modifier,
|
||||
) {
|
||||
CheckHiddenFeedWatchBlockAndReport(
|
||||
note = baseNote,
|
||||
modifier = modifier,
|
||||
ignoreAllBlocksAndReports = isHiddenFeed,
|
||||
showHiddenWarning = isQuotedNote || isBoostedNote,
|
||||
TracedComposition(NoteTrace.CARD) {
|
||||
WatchNoteEvent(
|
||||
baseNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) { canPreview ->
|
||||
AcceptableNote(
|
||||
baseNote = baseNote,
|
||||
nav,
|
||||
modifier,
|
||||
) {
|
||||
CheckHiddenFeedWatchBlockAndReport(
|
||||
note = baseNote,
|
||||
modifier = modifier,
|
||||
routeForLastRead = routeForLastRead,
|
||||
isBoostedNote = isBoostedNote,
|
||||
isQuotedNote = isQuotedNote,
|
||||
unPackReply = unPackReply,
|
||||
makeItShort = makeItShort,
|
||||
canPreview = canPreview,
|
||||
isPinned = isPinned,
|
||||
quotesLeft = quotesLeft,
|
||||
parentBackgroundColor = parentBackgroundColor,
|
||||
ignoreAllBlocksAndReports = isHiddenFeed,
|
||||
showHiddenWarning = isQuotedNote || isBoostedNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClick = onClick,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
) { canPreview ->
|
||||
AcceptableNote(
|
||||
baseNote = baseNote,
|
||||
modifier = modifier,
|
||||
routeForLastRead = routeForLastRead,
|
||||
isBoostedNote = isBoostedNote,
|
||||
isQuotedNote = isQuotedNote,
|
||||
unPackReply = unPackReply,
|
||||
makeItShort = makeItShort,
|
||||
canPreview = canPreview,
|
||||
isPinned = isPinned,
|
||||
quotesLeft = quotesLeft,
|
||||
parentBackgroundColor = parentBackgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClick = onClick,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -749,44 +751,52 @@ fun InnerNoteWithReactions(
|
||||
showContentSpacer = isNotRepost,
|
||||
authorPicture = {
|
||||
if (notBoostedNorQuote) {
|
||||
Box(modifier = Size55Modifier, contentAlignment = Alignment.BottomEnd) {
|
||||
RenderAuthorImages(baseNote, nav, accountViewModel)
|
||||
TracedComposition(NoteTrace.AUTHOR_IMAGES) {
|
||||
Box(modifier = Size55Modifier.tracedDraw(NoteTrace.DRAW_AUTHOR), contentAlignment = Alignment.BottomEnd) {
|
||||
RenderAuthorImages(baseNote, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
firstRow = {
|
||||
FirstUserInfoRow(
|
||||
baseNote = baseNote,
|
||||
showAuthorPicture = isQuotedNote,
|
||||
isPinned = isPinned,
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
},
|
||||
secondRow = {
|
||||
if (showSecondRow) {
|
||||
SecondUserInfoRow(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
nav,
|
||||
TracedComposition(NoteTrace.FIRST_ROW) {
|
||||
FirstUserInfoRow(
|
||||
baseNote = baseNote,
|
||||
showAuthorPicture = isQuotedNote,
|
||||
isPinned = isPinned,
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
}
|
||||
},
|
||||
secondRow = {
|
||||
if (showSecondRow) {
|
||||
TracedComposition(NoteTrace.SECOND_ROW) {
|
||||
SecondUserInfoRow(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
noteContent = {
|
||||
RenderNoteRow(
|
||||
baseNote = baseNote,
|
||||
backgroundColor = backgroundColor,
|
||||
makeItShort = makeItShort,
|
||||
canPreview = canPreview,
|
||||
editState = editState,
|
||||
quotesLeft = quotesLeft,
|
||||
unPackReply = unPackReply,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
isBoostedNote = isBoostedNote,
|
||||
)
|
||||
TracedComposition(NoteTrace.CONTENT) {
|
||||
RenderNoteRow(
|
||||
baseNote = baseNote,
|
||||
backgroundColor = backgroundColor,
|
||||
makeItShort = makeItShort,
|
||||
canPreview = canPreview,
|
||||
editState = editState,
|
||||
quotesLeft = quotesLeft,
|
||||
unPackReply = unPackReply,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
isBoostedNote = isBoostedNote,
|
||||
)
|
||||
}
|
||||
|
||||
if (!makeItShort) {
|
||||
val noteEvent = baseNote.event
|
||||
@@ -804,14 +814,16 @@ fun InnerNoteWithReactions(
|
||||
if (makeItShort) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
} else {
|
||||
ReactionsRow(
|
||||
baseNote = baseNote,
|
||||
showReactionDetail = notBoostedNorQuote,
|
||||
addPadding = !isBoostedNote,
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
TracedComposition(NoteTrace.REACTIONS) {
|
||||
ReactionsRow(
|
||||
baseNote = baseNote,
|
||||
showReactionDetail = notBoostedNorQuote,
|
||||
addPadding = !isBoostedNote,
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (baseNote.event is DraftWrapEvent) {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
@@ -1867,7 +1879,7 @@ fun FirstUserInfoRow(
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
modifier = UserNameRowHeight,
|
||||
modifier = UserNameRowHeight.tracedDraw(NoteTrace.DRAW_FIRST_ROW),
|
||||
) {
|
||||
val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
|
||||
val isDraft = baseNote.isDraft()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.tracing.Trace
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.ReactionRowAction
|
||||
|
||||
/**
|
||||
* Composition-time trace markers for the feed's note card.
|
||||
*
|
||||
* Compose runs a composable's body synchronously on the composing thread, so a
|
||||
* `beginSection`/`endSection` pair wrapped around a `content()` call measures the
|
||||
* **composition** cost of that subtree — deliberately excluding the later measure,
|
||||
* layout and draw phases, which is what makes this useful for separating "building
|
||||
* the tree" from "drawing it".
|
||||
*
|
||||
* Gated on [BuildConfig.TRACE_NOTE_RENDER], `true` only in the `benchmark` build type, so
|
||||
* the sections never execute in `debug` or `release`.
|
||||
*
|
||||
* They are **not stripped**, though — verified by grepping the R8'd release DEX, which
|
||||
* still contains the marker strings, this file's classes and a reference to
|
||||
* `androidx.tracing.Trace`. R8 cannot prove the branch dead, most likely because this is
|
||||
* a `@Composable inline` function the Compose plugin rewrites before R8 sees it. Runtime
|
||||
* cost in a shipped build is nil; APK footprint is not zero. Before this lands on `main`,
|
||||
* move the tracer behind a source-set split (no-op for debug/release, real one only in
|
||||
* `benchmark`).
|
||||
*
|
||||
* Section names are read back by `TraceSectionMetric` in
|
||||
* `:macrobenchmark`'s `FeedScrollBenchmark`, so they must stay in sync with the names
|
||||
* listed there.
|
||||
*/
|
||||
@Composable
|
||||
inline fun TracedComposition(
|
||||
name: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (!BuildConfig.TRACE_NOTE_RENDER) {
|
||||
content()
|
||||
return
|
||||
}
|
||||
|
||||
// No try/finally: the Compose compiler rejects a try/catch around a composable call.
|
||||
// An exception thrown during composition aborts the frame regardless, so an unbalanced
|
||||
// section in that case is not a concern.
|
||||
Trace.beginSection(name)
|
||||
content()
|
||||
Trace.endSection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw-phase counterpart of [TracedComposition].
|
||||
*
|
||||
* Composition markers cannot see the draw phase at all, and on a slow device draw is roughly three
|
||||
* times the whole layout phase. `drawWithContent` wraps the subtree's paint work, so the section
|
||||
* measures exactly the time spent rasterising that part of the card.
|
||||
*
|
||||
* Deliberately attached to modifiers that already exist on real composables rather than to new
|
||||
* wrapper `Box`es: adding a node would change the layout being measured. Benchmark builds only —
|
||||
* elsewhere the constant folds and this returns the receiver untouched.
|
||||
*/
|
||||
fun Modifier.tracedDraw(name: String): Modifier =
|
||||
if (!BuildConfig.TRACE_NOTE_RENDER) {
|
||||
this
|
||||
} else {
|
||||
drawWithContent {
|
||||
Trace.beginSection(name)
|
||||
drawContent()
|
||||
Trace.endSection()
|
||||
}
|
||||
}
|
||||
|
||||
/** Section names. Kept as constants so R8 cannot rewrite them apart from the metric list. */
|
||||
object NoteTrace {
|
||||
const val CARD = "Amethyst:NoteCard"
|
||||
const val WATCH_EVENT = "Amethyst:WatchNoteEvent"
|
||||
const val HIDDEN_CHECK = "Amethyst:HiddenCheck"
|
||||
const val BG_COLOR = "Amethyst:BackgroundColor"
|
||||
const val AUTHOR_IMAGES = "Amethyst:AuthorImages"
|
||||
const val FIRST_ROW = "Amethyst:FirstUserInfoRow"
|
||||
const val SECOND_ROW = "Amethyst:SecondUserInfoRow"
|
||||
const val CONTENT = "Amethyst:NoteContent"
|
||||
const val REACTIONS = "Amethyst:ReactionsRow"
|
||||
const val DISPATCH = "Amethyst:KindDispatch"
|
||||
|
||||
// Draw-phase sections (see Modifier.tracedDraw).
|
||||
const val DRAW_REACTIONS = "Amethyst:DrawReactions"
|
||||
const val DRAW_AUTHOR = "Amethyst:DrawAuthor"
|
||||
const val DRAW_FIRST_ROW = "Amethyst:DrawFirstRow"
|
||||
const val DRAW_RICHTEXT = "Amethyst:DrawRichText"
|
||||
|
||||
/** Fires only when a feed icon actually used the shared painter — proves the fix is live. */
|
||||
const val SHARED_PAINTER = "Amethyst:SharedPainter"
|
||||
|
||||
// Drill-down inside ReactionsRow, the single most expensive slot of the card.
|
||||
const val RX_INDICATORS = "Amethyst:RxIndicators"
|
||||
const val RX_ZAPRAISER = "Amethyst:RxZapraiser"
|
||||
const val RX_REPLY = "Amethyst:RxReply"
|
||||
const val RX_BOOST = "Amethyst:RxBoost"
|
||||
const val RX_LIKE = "Amethyst:RxLike"
|
||||
const val RX_ZAP = "Amethyst:RxZap"
|
||||
const val RX_SHARE = "Amethyst:RxShare"
|
||||
const val RX_PAY = "Amethyst:RxPay"
|
||||
|
||||
// Drill-down inside NoteContent for a plain text note (the common feed case).
|
||||
const val TXT_REPLY = "Amethyst:TxtReplyPreview"
|
||||
const val TXT_RICHTEXT = "Amethyst:TxtRichText"
|
||||
const val TXT_HASHTAGS = "Amethyst:TxtHashtags"
|
||||
|
||||
/** Section name for one reaction-row button. */
|
||||
fun forAction(action: ReactionRowAction) =
|
||||
when (action) {
|
||||
ReactionRowAction.Reply -> RX_REPLY
|
||||
ReactionRowAction.Boost -> RX_BOOST
|
||||
ReactionRowAction.Like -> RX_LIKE
|
||||
ReactionRowAction.Zap -> RX_ZAP
|
||||
ReactionRowAction.Share -> RX_SHARE
|
||||
ReactionRowAction.Pay -> RX_PAY
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -48,6 +49,7 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
@@ -106,14 +108,17 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
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.commons.icons.symbols.amethystIconsFontFamily
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
|
||||
@@ -126,6 +131,7 @@ import com.vitorpamplona.amethyst.model.zap.CashuRailStatus
|
||||
import com.vitorpamplona.amethyst.model.zap.RailCapability
|
||||
import com.vitorpamplona.amethyst.model.zap.RailCapabilityResolver
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.collectAsStateProbed
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount
|
||||
@@ -143,6 +149,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBoxLazyRipple
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -227,7 +234,9 @@ fun ReactionsRow(
|
||||
|
||||
InnerReactionRow(baseNote, showReactionDetail, addPadding, wantsToSeeReactions, editState, accountViewModel, nav)
|
||||
|
||||
LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel)
|
||||
TracedComposition(NoteTrace.RX_ZAPRAISER) {
|
||||
LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel)
|
||||
}
|
||||
|
||||
if (showReactionDetail && wantsToSeeReactions.value) {
|
||||
ReactionDetailGallery(baseNote, nav, accountViewModel)
|
||||
@@ -246,15 +255,17 @@ private fun InnerReactionRow(
|
||||
nav: INav,
|
||||
) {
|
||||
val voiceRecordingState = remember(baseNote.idHex) { mutableStateOf(false) }
|
||||
val reactionRowItems by accountViewModel.reactionRowItemsFlow().collectAsStateWithLifecycle()
|
||||
val reactionRowItems by accountViewModel.reactionRowItemsFlow().collectAsStateProbed()
|
||||
|
||||
GenericInnerReactionRow(
|
||||
showReactionDetail = showReactionDetail,
|
||||
addPadding = addPadding,
|
||||
weightTwo = if (voiceRecordingState.value) 2f else 1f,
|
||||
one = {
|
||||
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) {
|
||||
RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel)
|
||||
TracedComposition(NoteTrace.RX_INDICATORS) {
|
||||
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) {
|
||||
RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel)
|
||||
}
|
||||
}
|
||||
},
|
||||
reactions = reactionRowItems,
|
||||
@@ -270,71 +281,73 @@ private fun InnerReactionRow(
|
||||
// shared: the reference points at a membership-gated group event that non-members can't fetch,
|
||||
// and a DM shouldn't be rebroadcast at all. Reply/like/zap stay (reply routes into the group).
|
||||
val isRelayGroupMessage = baseNote.inGatherers?.any { it is RelayGroupChannel } == true
|
||||
when (item.action) {
|
||||
ReactionRowAction.Reply -> {
|
||||
ReplyReactionWithDialog(
|
||||
baseNote,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav,
|
||||
showCounter = item.showCounter,
|
||||
voiceRecordingState = voiceRecordingState,
|
||||
)
|
||||
}
|
||||
|
||||
ReactionRowAction.Boost -> {
|
||||
val isDM = baseNote.event is ChatroomKeyable
|
||||
if (!isDM && !isPrivateRumor && !isRelayGroupMessage) {
|
||||
BoostWithDialog(
|
||||
TracedComposition(NoteTrace.forAction(item.action)) {
|
||||
when (item.action) {
|
||||
ReactionRowAction.Reply -> {
|
||||
ReplyReactionWithDialog(
|
||||
baseNote,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav,
|
||||
showCounter = item.showCounter,
|
||||
voiceRecordingState = voiceRecordingState,
|
||||
)
|
||||
}
|
||||
|
||||
ReactionRowAction.Boost -> {
|
||||
val isDM = baseNote.event is ChatroomKeyable
|
||||
if (!isDM && !isPrivateRumor && !isRelayGroupMessage) {
|
||||
BoostWithDialog(
|
||||
baseNote,
|
||||
editState,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav,
|
||||
showCounter = item.showCounter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ReactionRowAction.Like -> {
|
||||
LikeReaction(
|
||||
baseNote,
|
||||
editState,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav,
|
||||
showCounter = item.showCounter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ReactionRowAction.Like -> {
|
||||
LikeReaction(
|
||||
baseNote,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav,
|
||||
showCounter = item.showCounter,
|
||||
)
|
||||
}
|
||||
|
||||
ReactionRowAction.Zap -> {
|
||||
// Zaps stay enabled on private rumors: AccountViewModel.zap
|
||||
// forces the PRIVATE zap type, and the public nutzap/onchain
|
||||
// rails are suppressed for them.
|
||||
ZapReaction(
|
||||
baseNote,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav = nav,
|
||||
showCounter = item.showCounter,
|
||||
)
|
||||
}
|
||||
|
||||
ReactionRowAction.Share -> {
|
||||
if (!isPrivateRumor && !isRelayGroupMessage) {
|
||||
ShareReaction(
|
||||
note = baseNote,
|
||||
ReactionRowAction.Zap -> {
|
||||
// Zaps stay enabled on private rumors: AccountViewModel.zap
|
||||
// forces the PRIVATE zap type, and the public nutzap/onchain
|
||||
// rails are suppressed for them.
|
||||
ZapReaction(
|
||||
baseNote,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav = nav,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
showCounter = item.showCounter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ReactionRowAction.Pay -> {
|
||||
PayReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
ReactionRowAction.Share -> {
|
||||
if (!isPrivateRumor && !isRelayGroupMessage) {
|
||||
ShareReaction(
|
||||
note = baseNote,
|
||||
nav = nav,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ReactionRowAction.Pay -> {
|
||||
PayReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -350,11 +363,11 @@ fun ShareReaction(
|
||||
) {
|
||||
var showShareSheet by remember { mutableStateOf(false) }
|
||||
|
||||
ClickableBox(
|
||||
RxClickableBox(
|
||||
modifier = barChartModifier,
|
||||
onClick = { showShareSheet = true },
|
||||
) {
|
||||
ShareIcon(barChartModifier, grayTint)
|
||||
RxIcon(barChartModifier, RxIconKind.GLYPH) { ShareIcon(barChartModifier, grayTint) }
|
||||
}
|
||||
|
||||
if (showShareSheet) {
|
||||
@@ -379,7 +392,7 @@ fun PayReaction(
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ClickableBox(
|
||||
RxClickableBox(
|
||||
modifier = iconSizeModifier,
|
||||
onClick = { expanded = true },
|
||||
) {
|
||||
@@ -416,7 +429,7 @@ private fun GenericInnerReactionRow(
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = RowColSpacing,
|
||||
modifier = if (addPadding) ReactionRowHeightWithPadding else ReactionRowHeight,
|
||||
modifier = (if (addPadding) ReactionRowHeightWithPadding else ReactionRowHeight).tracedDraw(NoteTrace.DRAW_REACTIONS),
|
||||
) {
|
||||
if (showReactionDetail) {
|
||||
Row(
|
||||
@@ -544,19 +557,19 @@ private fun RenderShowIndividualReactionsButton(
|
||||
wantsToSeeReactions: MutableState<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
ClickableBox(
|
||||
RxClickableBox(
|
||||
onClick = { wantsToSeeReactions.value = !wantsToSeeReactions.value },
|
||||
modifier = Size20Modifier,
|
||||
) {
|
||||
CrossfadeIfEnabled(
|
||||
RxCrossfade(
|
||||
targetState = wantsToSeeReactions.value,
|
||||
label = "RenderShowIndividualReactionsButton",
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
if (it) {
|
||||
ExpandLessIcon(modifier = Size22Modifier, R.string.close_all_reactions_to_this_post)
|
||||
RxIcon(Size22Modifier, RxIconKind.GLYPH) { ExpandLessIcon(modifier = Size22Modifier, R.string.close_all_reactions_to_this_post) }
|
||||
} else {
|
||||
ExpandMoreIcon(modifier = Size22Modifier, R.string.open_all_reactions_to_this_post)
|
||||
RxIcon(Size22Modifier, RxIconKind.GLYPH) { ExpandMoreIcon(modifier = Size22Modifier, R.string.open_all_reactions_to_this_post) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -791,7 +804,7 @@ fun ReplyViaVoiceReaction(
|
||||
onClick = onStop,
|
||||
)
|
||||
} else {
|
||||
VoiceReplyIcon(iconSizeModifier, grayTint)
|
||||
RxIcon(iconSizeModifier, RxIconKind.GLYPH) { VoiceReplyIcon(iconSizeModifier, grayTint) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -809,7 +822,7 @@ fun ReplyReaction(
|
||||
iconSizeModifier: Modifier = Size19Modifier,
|
||||
onPress: () -> Unit,
|
||||
) {
|
||||
ClickableBox(
|
||||
RxClickableBox(
|
||||
modifier = iconSizeModifier,
|
||||
onClick = {
|
||||
if (baseNote.isDraft()) {
|
||||
@@ -829,7 +842,7 @@ fun ReplyReaction(
|
||||
}
|
||||
},
|
||||
) {
|
||||
CommentIcon(iconSizeModifier, grayTint)
|
||||
RxIcon(iconSizeModifier, RxIconKind.VECTOR) { FeedCommentIcon(iconSizeModifier, grayTint) }
|
||||
}
|
||||
|
||||
if (showCounter) {
|
||||
@@ -848,14 +861,294 @@ fun ReplyCounter(
|
||||
SlidingAnimationCount(repliesState, textColor, accountViewModel)
|
||||
}
|
||||
|
||||
/** 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 whole apparatus is 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
|
||||
internal fun <T> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaction-row icons that prefer the feed's shared [VectorPainter] when one is available.
|
||||
*
|
||||
* Falls back to the ordinary `Icon(imageVector = …)` path whenever no feed provided painters — a
|
||||
* preview, a dialog, or any screen outside the feed — so behaviour and appearance are unchanged
|
||||
* everywhere. See [FeedReactionPainters] for why the sharing is scoped rather than global.
|
||||
*/
|
||||
@Composable
|
||||
private fun FeedCommentIcon(
|
||||
iconSizeModifier: Modifier,
|
||||
tint: Color,
|
||||
) {
|
||||
if (BuildConfig.PROBE_RX_CUSTOM_FONT) {
|
||||
Material3Icon(
|
||||
painter = rememberMaterialSymbolPainter(AmethystIcons.Reply, tint, amethystIconsFontFamily()),
|
||||
contentDescription = null,
|
||||
modifier = iconSizeModifier,
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (BuildConfig.PROBE_RX_GLYPH_SWAP) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Chat,
|
||||
contentDescription = stringRes(id = R.string.reply_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = tint,
|
||||
)
|
||||
return
|
||||
}
|
||||
val shared = LocalFeedReactionPainters.current
|
||||
if (BuildConfig.FIX_SHARED_ICONS && shared != null) {
|
||||
TracedComposition(NoteTrace.SHARED_PAINTER) {
|
||||
Material3Icon(
|
||||
painter = shared.reply,
|
||||
contentDescription = stringRes(id = R.string.reply_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CommentIcon(iconSizeModifier, tint)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedRepostedIcon(
|
||||
iconSizeModifier: Modifier,
|
||||
tint: Color,
|
||||
) {
|
||||
if (BuildConfig.PROBE_RX_CUSTOM_FONT) {
|
||||
Material3Icon(
|
||||
painter = rememberMaterialSymbolPainter(AmethystIcons.Reposted, tint, amethystIconsFontFamily()),
|
||||
contentDescription = null,
|
||||
modifier = iconSizeModifier,
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (BuildConfig.PROBE_RX_GLYPH_SWAP) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Sync,
|
||||
contentDescription = stringRes(id = R.string.boost_or_quote_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = tint,
|
||||
)
|
||||
return
|
||||
}
|
||||
val shared = LocalFeedReactionPainters.current
|
||||
if (BuildConfig.FIX_SHARED_ICONS && shared != null) {
|
||||
TracedComposition(NoteTrace.SHARED_PAINTER) {
|
||||
Material3Icon(
|
||||
painter = shared.reposted,
|
||||
contentDescription = stringRes(id = R.string.boost_or_quote_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RepostedIcon(iconSizeModifier, tint)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedLikeIcon(
|
||||
iconSizeModifier: Modifier,
|
||||
tint: Color,
|
||||
) {
|
||||
if (BuildConfig.PROBE_RX_CUSTOM_FONT) {
|
||||
Material3Icon(
|
||||
painter = rememberMaterialSymbolPainter(AmethystIcons.Like, tint, amethystIconsFontFamily()),
|
||||
contentDescription = null,
|
||||
modifier = iconSizeModifier,
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (BuildConfig.PROBE_RX_GLYPH_SWAP) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Favorite,
|
||||
contentDescription = stringRes(id = R.string.like_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = tint,
|
||||
)
|
||||
return
|
||||
}
|
||||
val shared = LocalFeedReactionPainters.current
|
||||
if (BuildConfig.FIX_SHARED_ICONS && shared != null) {
|
||||
TracedComposition(NoteTrace.SHARED_PAINTER) {
|
||||
Material3Icon(
|
||||
painter = shared.like,
|
||||
contentDescription = stringRes(id = R.string.like_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LikeIcon(iconSizeModifier, tint)
|
||||
}
|
||||
}
|
||||
|
||||
/** Which rasterisation path an icon takes, so the two can be ablated independently. */
|
||||
private enum class RxIconKind {
|
||||
/** `Icon(imageVector = …)` — a VectorPainter rasterising paths into a cached layer. */
|
||||
VECTOR,
|
||||
|
||||
/** `Icon(symbol = …)` — a glyph from the bundled MaterialSymbols subset font. */
|
||||
GLYPH,
|
||||
}
|
||||
|
||||
/**
|
||||
* **Measurement probe helper — not a feature.**
|
||||
*
|
||||
* Draws nothing where a reaction icon would go, keeping a node of the same size so the row lays out
|
||||
* unchanged. [kind] lets the vector-path icons and the MaterialSymbols glyph icons be ablated
|
||||
* separately, since a combined ablation says only how much the icons cost in total, not which
|
||||
* rasterisation path is responsible.
|
||||
*
|
||||
* Probe builds show a row of blank gaps; it exists to be measured, not shipped.
|
||||
*/
|
||||
@Composable
|
||||
private inline fun RxIcon(
|
||||
modifier: Modifier,
|
||||
kind: RxIconKind,
|
||||
icon: @Composable () -> Unit,
|
||||
) {
|
||||
val hide =
|
||||
BuildConfig.PROBE_NO_RX_ICONS ||
|
||||
when (kind) {
|
||||
RxIconKind.VECTOR -> BuildConfig.PROBE_NO_RX_VECTOR_ICONS
|
||||
RxIconKind.GLYPH -> BuildConfig.PROBE_NO_RX_GLYPH_ICONS
|
||||
}
|
||||
if (hide) {
|
||||
Spacer(modifier)
|
||||
} else {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **Measurement probe helper — not a feature.**
|
||||
*
|
||||
* When `PROBE_NO_RX_CLICKABLE` is on, renders the reaction button's content in a bare [Box] with
|
||||
* no `clickable` modifier at all — no `MutableInteractionSource` allocated, no ripple
|
||||
* [androidx.compose.foundation.IndicationNodeFactory] attached, no `Role.Button` semantics.
|
||||
*
|
||||
* This is an upper bound, not a candidate fix: a probe build's reaction buttons do not respond to
|
||||
* taps. It exists to answer whether the ~6 eagerly-built clickable/ripple nodes per row are worth
|
||||
* optimising before any behaviour-preserving version (a lazy interaction source) is attempted.
|
||||
*/
|
||||
@Composable
|
||||
private fun RxClickableBox(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
if (BuildConfig.PROBE_NO_RX_CLICKABLE) {
|
||||
Box(modifier, contentAlignment = Alignment.Center, content = content)
|
||||
} else if (BuildConfig.PROBE_LAZY_RX_RIPPLE) {
|
||||
ClickableBoxLazyRipple(modifier = modifier, onClick = onClick, content = content)
|
||||
} else {
|
||||
ClickableBox(modifier = modifier, onClick = onClick, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RxClickableBox(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (BuildConfig.PROBE_NO_RX_CLICKABLE) {
|
||||
Box(modifier, contentAlignment = Alignment.Center) { content() }
|
||||
} else if (BuildConfig.PROBE_LAZY_RX_RIPPLE) {
|
||||
ClickableBoxLazyRipple(modifier = modifier, onClick = onClick, onLongClick = onLongClick, content = content)
|
||||
} else {
|
||||
ClickableBox(modifier = modifier, onClick = onClick, onLongClick = onLongClick, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **Measurement probe helper — not a feature.**
|
||||
*
|
||||
* Same contract as [CrossfadeIfEnabled], but when `PROBE_NO_RX_ANIMATIONS` is on it takes the
|
||||
* non-animated branch that `CrossfadeIfEnabled` already has for performance mode: the identical
|
||||
* `Box` + content, with no `updateTransition` built. Scoped to the reaction row on purpose, so
|
||||
* the rest of the card keeps animating and acts as a control.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> RxCrossfade(
|
||||
targetState: T,
|
||||
modifier: Modifier = Modifier,
|
||||
contentAlignment: Alignment = Alignment.TopStart,
|
||||
label: String = "Crossfade",
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
if (BuildConfig.PROBE_NO_RX_ANIMATIONS) {
|
||||
Box(modifier, contentAlignment) {
|
||||
content(targetState)
|
||||
}
|
||||
} else {
|
||||
CrossfadeIfEnabled(
|
||||
targetState = targetState,
|
||||
modifier = modifier,
|
||||
contentAlignment = contentAlignment,
|
||||
label = label,
|
||||
accountViewModel = accountViewModel,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SlidingAnimationCount(
|
||||
baseCount: Int,
|
||||
textColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (accountViewModel.settings.isPerformanceMode()) {
|
||||
if (BuildConfig.PROBE_NO_RX_ANIMATIONS || accountViewModel.settings.isPerformanceMode()) {
|
||||
TextCount(baseCount, textColor)
|
||||
} else if (BuildConfig.FIX_LAZY_ANIM) {
|
||||
DeferredAnimatedContent(baseCount, "SlidingAnimationCount") { count ->
|
||||
TextCount(count, textColor)
|
||||
}
|
||||
} else {
|
||||
AnimatedContent(
|
||||
targetState = baseCount,
|
||||
@@ -889,6 +1182,13 @@ fun TextCount(
|
||||
count: Int,
|
||||
textColor: Color,
|
||||
) {
|
||||
if (BuildConfig.PROBE_NO_RX_COUNTERS) {
|
||||
// Probe: keep a layout node of roughly the same width so the row still lays out the same
|
||||
// way, but do no text shaping. The delta against a normal build is the counter's
|
||||
// Paragraph construction + glyph shaping.
|
||||
Spacer(ProbeCounterWidth)
|
||||
return
|
||||
}
|
||||
Text(
|
||||
text = showCount(count),
|
||||
fontSize = Font14SP,
|
||||
@@ -897,13 +1197,20 @@ fun TextCount(
|
||||
)
|
||||
}
|
||||
|
||||
/** Stand-in width for a 2-3 digit counter, probe builds only. */
|
||||
private val ProbeCounterWidth = Modifier.width(20.dp)
|
||||
|
||||
@Composable
|
||||
fun SlidingAnimationAmount(
|
||||
amount: String,
|
||||
textColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (accountViewModel.settings.isPerformanceMode()) {
|
||||
if (BuildConfig.PROBE_NO_RX_COUNTERS) {
|
||||
Spacer(ProbeCounterWidth)
|
||||
return
|
||||
}
|
||||
if (BuildConfig.PROBE_NO_RX_ANIMATIONS || accountViewModel.settings.isPerformanceMode()) {
|
||||
Text(
|
||||
text = amount,
|
||||
fontSize = Font14SP,
|
||||
@@ -911,17 +1218,28 @@ fun SlidingAnimationAmount(
|
||||
maxLines = 1,
|
||||
)
|
||||
} else {
|
||||
AnimatedContent(
|
||||
targetState = amount,
|
||||
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec,
|
||||
label = "SlidingAnimationAmount",
|
||||
) { count ->
|
||||
Text(
|
||||
text = count,
|
||||
fontSize = Font14SP,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
if (BuildConfig.FIX_LAZY_ANIM) {
|
||||
DeferredAnimatedContent(amount, "SlidingAnimationAmount") { count ->
|
||||
Text(
|
||||
text = count,
|
||||
fontSize = Font14SP,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AnimatedContent(
|
||||
targetState = amount,
|
||||
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec,
|
||||
label = "SlidingAnimationAmount",
|
||||
) { count ->
|
||||
Text(
|
||||
text = count,
|
||||
fontSize = Font14SP,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -939,14 +1257,14 @@ fun BoostReaction(
|
||||
) {
|
||||
var wantsToBoost by remember { mutableStateOf(false) }
|
||||
|
||||
ClickableBox(
|
||||
RxClickableBox(
|
||||
modifier = iconSizeModifier,
|
||||
onClick = {
|
||||
accountViewModel.tryBoost(baseNote) { wantsToBoost = true }
|
||||
},
|
||||
) {
|
||||
ObserveBoostIcon(baseNote, accountViewModel) { hasBoosted ->
|
||||
RepostedIcon(iconSizeModifier, if (hasBoosted) Color.Unspecified else grayTint)
|
||||
RxIcon(iconSizeModifier, RxIconKind.VECTOR) { FeedRepostedIcon(iconSizeModifier, if (hasBoosted) Color.Unspecified else grayTint) }
|
||||
}
|
||||
|
||||
if (wantsToBoost) {
|
||||
@@ -1010,7 +1328,7 @@ fun LikeReaction(
|
||||
) {
|
||||
var wantsToReact by remember { mutableStateOf(false) }
|
||||
|
||||
ClickableBox(
|
||||
RxClickableBox(
|
||||
onClick = {
|
||||
likeClick(
|
||||
accountViewModel,
|
||||
@@ -1022,11 +1340,11 @@ fun LikeReaction(
|
||||
onLongClick = { nav.nav(Route.UpdateReactionType) },
|
||||
) {
|
||||
ObserveLikeIcon(baseNote, accountViewModel) { reactionType ->
|
||||
CrossfadeIfEnabled(targetState = reactionType, contentAlignment = Center, label = "LikeIcon", accountViewModel = accountViewModel) {
|
||||
RxCrossfade(targetState = reactionType, contentAlignment = Center, label = "LikeIcon", accountViewModel = accountViewModel) {
|
||||
if (reactionType != null) {
|
||||
RenderReactionType(reactionType, heartSizeModifier, iconFontSize)
|
||||
} else {
|
||||
LikeIcon(heartSizeModifier, grayTint)
|
||||
RxIcon(heartSizeModifier, RxIconKind.VECTOR) { FeedLikeIcon(heartSizeModifier, grayTint) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1060,10 +1378,16 @@ fun ObserveLikeIcon(
|
||||
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val reactionType by
|
||||
produceState(initialValue = null as String?, key1 = reactionsState) {
|
||||
val newReactionType = accountViewModel.loadReactionTo(reactionsState?.note)
|
||||
if (value != newReactionType) {
|
||||
value = newReactionType
|
||||
if (BuildConfig.PROBE_NO_RX_PRODUCE_STATE) {
|
||||
// Probe: same initial value produceState would show on first composition, but the
|
||||
// coroutine that resolves the user's own reaction is never launched.
|
||||
remember { mutableStateOf(null as String?) }
|
||||
} else {
|
||||
produceState(initialValue = null as String?, key1 = reactionsState) {
|
||||
val newReactionType = accountViewModel.loadReactionTo(reactionsState?.note)
|
||||
if (value != newReactionType) {
|
||||
value = newReactionType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1355,9 +1679,9 @@ fun ZapReaction(
|
||||
accountViewModel,
|
||||
zapStartingTime,
|
||||
) { zapIconState ->
|
||||
CrossfadeIfEnabled(targetState = zapIconState, label = "ZapIcon", accountViewModel = accountViewModel) {
|
||||
RxCrossfade(targetState = zapIconState, label = "ZapIcon", accountViewModel = accountViewModel) {
|
||||
if (it.wasZappedByLoggedInUser) {
|
||||
ZappedIcon(iconSizeModifier)
|
||||
RxIcon(iconSizeModifier, RxIconKind.GLYPH) { ZappedIcon(iconSizeModifier) }
|
||||
} else {
|
||||
TwoStageZapProgressIcon(animatedProgress, animationModifier, grayTint)
|
||||
}
|
||||
@@ -1368,11 +1692,11 @@ fun ZapReaction(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
) { zapIconState ->
|
||||
CrossfadeIfEnabled(targetState = zapIconState, label = "ZapIcon", accountViewModel = accountViewModel) {
|
||||
RxCrossfade(targetState = zapIconState, label = "ZapIcon", accountViewModel = accountViewModel) {
|
||||
if (it.wasZappedByLoggedInUser) {
|
||||
ZappedIcon(iconSizeModifier)
|
||||
RxIcon(iconSizeModifier, RxIconKind.GLYPH) { ZappedIcon(iconSizeModifier) }
|
||||
} else if (it.hasPendingPaymentRequest) {
|
||||
ZapIcon(iconSizeModifier, MaterialTheme.colorScheme.primary)
|
||||
RxIcon(iconSizeModifier, RxIconKind.GLYPH) { ZapIcon(iconSizeModifier, MaterialTheme.colorScheme.primary) }
|
||||
} else {
|
||||
OutlinedZapIcon(iconSizeModifier, grayTint)
|
||||
}
|
||||
@@ -1590,11 +1914,17 @@ fun ObserveZapAmountText(
|
||||
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val zapAmountTxt by
|
||||
produceState(initialValue = showAmount(baseNote.zapsAmount), key1 = zapsState) {
|
||||
zapsState?.note?.let {
|
||||
val newZapAmount = accountViewModel.calculateZapAmount(it)
|
||||
if (value != newZapAmount) {
|
||||
value = newZapAmount
|
||||
if (BuildConfig.PROBE_NO_RX_PRODUCE_STATE) {
|
||||
// Probe: produceState's own initial value, so the rendered amount is identical on
|
||||
// first composition; only the recalculation coroutine is skipped.
|
||||
remember { mutableStateOf(showAmount(baseNote.zapsAmount)) }
|
||||
} else {
|
||||
produceState(initialValue = showAmount(baseNote.zapsAmount), key1 = zapsState) {
|
||||
zapsState?.note?.let {
|
||||
val newZapAmount = accountViewModel.calculateZapAmount(it)
|
||||
if (value != newZapAmount) {
|
||||
value = newZapAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1851,7 +2181,7 @@ fun ReactionChoicePopup(
|
||||
) {
|
||||
val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() }
|
||||
|
||||
val reactions by accountViewModel.reactionChoicesFlow().collectAsStateWithLifecycle()
|
||||
val reactions by accountViewModel.reactionChoicesFlow().collectAsStateProbed()
|
||||
val toRemove = remember { baseNote.allReactionsByAuthor(accountViewModel.userProfile()).toImmutableSet() }
|
||||
|
||||
Popup(
|
||||
@@ -1909,7 +2239,7 @@ fun ReactionChoicePopupContent(
|
||||
)
|
||||
}
|
||||
|
||||
ClickableBox(modifier = reactionBox, onClick = onChangeAmount) {
|
||||
RxClickableBox(modifier = reactionBox, onClick = onChangeAmount) {
|
||||
ChangeReactionIcon(modifier = Size28Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
}
|
||||
@@ -1960,7 +2290,7 @@ private fun ActionableReactionButton(
|
||||
onChangeAmount: () -> Unit,
|
||||
toRemove: ImmutableSet<String>,
|
||||
) {
|
||||
ClickableBox(
|
||||
RxClickableBox(
|
||||
modifier = if (reactionType in toRemove) MaterialTheme.colorScheme.selectedReactionBoxModifier else reactionBox,
|
||||
onClick = onClick,
|
||||
onLongClick = onChangeAmount,
|
||||
@@ -2034,7 +2364,7 @@ fun ZapAmountChoicePopup(
|
||||
) {
|
||||
val zapAmountChoices by
|
||||
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
|
||||
.collectAsStateWithLifecycle()
|
||||
.collectAsStateProbed()
|
||||
|
||||
ZapAmountChoicePopup(
|
||||
baseNote = baseNote,
|
||||
@@ -2073,15 +2403,15 @@ fun observeZapRailCapability(
|
||||
// open), and each value is a remember() key so railCapability recomputes when
|
||||
// it arrives. RailCapabilityResolver.peek re-reads everything itself; these
|
||||
// just say *when* to re-run it.
|
||||
val cashuMints by cashuState.mints.collectAsStateWithLifecycle()
|
||||
val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle()
|
||||
val cashuMints by cashuState.mints.collectAsStateProbed()
|
||||
val cashuEntries by cashuState.tokenEntries.collectAsStateProbed()
|
||||
val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value }
|
||||
val nutzapInfo = author?.let { observeNoteEvent<NutzapInfoEvent>(it.nutzapInfoNote, accountViewModel).value }
|
||||
// Honors the user's "show on-chain wallet" preference: off hides the on-chain
|
||||
// rail from the zap chips too, matching the wallet screen, profile chips, and
|
||||
// Send Payment screen.
|
||||
val showOnchainWallet by accountViewModel.settings.uiSettingsFlow.showOnchainWallet
|
||||
.collectAsStateWithLifecycle()
|
||||
.collectAsStateProbed()
|
||||
|
||||
return remember(baseNote, onchainSupported, showOnchainWallet, cashuMints, cashuEntries, recipientInfo, nutzapInfo) {
|
||||
val rc = RailCapabilityResolver.peek(baseNote, cashuState)
|
||||
@@ -2285,7 +2615,7 @@ fun ZapAmountChoiceGrid(
|
||||
onChangeAmount = onChangeAmount,
|
||||
)
|
||||
}
|
||||
ClickableBox(
|
||||
RxClickableBox(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp)
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -103,7 +104,11 @@ fun DisplayBlankAuthor(
|
||||
) {
|
||||
val nullModifier =
|
||||
remember {
|
||||
modifier.size(size).clip(shape = CircleShape)
|
||||
if (BuildConfig.PROBE_NO_AVATAR_CLIP || BuildConfig.PROBE_CIRCLE_CROP) {
|
||||
modifier.size(size)
|
||||
} else {
|
||||
modifier.size(size).clip(shape = CircleShape)
|
||||
}
|
||||
}
|
||||
|
||||
RobohashAsyncImage(
|
||||
@@ -621,7 +626,11 @@ fun InnerUserPicture(
|
||||
) {
|
||||
val myImageModifier =
|
||||
remember {
|
||||
modifier.size(size).clip(shape = CircleShape)
|
||||
if (BuildConfig.PROBE_NO_AVATAR_CLIP || BuildConfig.PROBE_CIRCLE_CROP) {
|
||||
modifier.size(size)
|
||||
} else {
|
||||
modifier.size(size).clip(shape = CircleShape)
|
||||
}
|
||||
}
|
||||
|
||||
RobohashFallbackAsyncImage(
|
||||
|
||||
@@ -45,9 +45,12 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteTrace
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
|
||||
import com.vitorpamplona.amethyst.ui.note.TracedComposition
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayCommentScope
|
||||
import com.vitorpamplona.amethyst.ui.note.tracedDraw
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.PreloadThreadForReply
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
|
||||
@@ -104,48 +107,50 @@ fun RenderTextEvent(
|
||||
val noteEvent = note.event ?: return
|
||||
|
||||
if (unPackReply != ReplyRenderType.NONE) {
|
||||
// Eagerly pull the rest of this reply's thread while it's on screen, so opening
|
||||
// the conversation finds it already loaded. No-op when the note is a root itself.
|
||||
PreloadThreadForReply(note, accountViewModel)
|
||||
TracedComposition(NoteTrace.TXT_REPLY) {
|
||||
// Eagerly pull the rest of this reply's thread while it's on screen, so opening
|
||||
// the conversation finds it already loaded. No-op when the note is a root itself.
|
||||
PreloadThreadForReply(note, accountViewModel)
|
||||
|
||||
val canShowReply by
|
||||
remember(note) {
|
||||
derivedStateOf {
|
||||
noteEvent is BaseThreadedEvent && !makeItShort && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
|
||||
}
|
||||
}
|
||||
|
||||
val parentNote = remember(note) { replyingDirectlyTo(note, LocalCache) }
|
||||
|
||||
if (parentNote != null && canShowReply) {
|
||||
when (unPackReply) {
|
||||
ReplyRenderType.FULL -> {
|
||||
ReplyNoteComposition(parentNote, backgroundColor, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
|
||||
ReplyRenderType.LINE -> {
|
||||
// Zap receipts are signed by the recipient's lightning provider;
|
||||
// label the reply with the zap sender instead of the service key.
|
||||
val zapSender =
|
||||
if (parentNote.event is LnZapEvent) {
|
||||
observeZapSender(parentNote, accountViewModel).value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val parentAuthor = zapSender ?: parentNote.author
|
||||
if (parentAuthor != null) {
|
||||
ReplyToLabel(
|
||||
parentAuthorDisplay = parentAuthor.toBestDisplayName(),
|
||||
onClick = { nav.nav(routeFor(parentAuthor)) },
|
||||
)
|
||||
Spacer(modifier = HalfVertSpacer)
|
||||
val canShowReply by
|
||||
remember(note) {
|
||||
derivedStateOf {
|
||||
noteEvent is BaseThreadedEvent && !makeItShort && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
|
||||
}
|
||||
}
|
||||
|
||||
val parentNote = remember(note) { replyingDirectlyTo(note, LocalCache) }
|
||||
|
||||
if (parentNote != null && canShowReply) {
|
||||
when (unPackReply) {
|
||||
ReplyRenderType.FULL -> {
|
||||
ReplyNoteComposition(parentNote, backgroundColor, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
|
||||
ReplyRenderType.LINE -> {
|
||||
// Zap receipts are signed by the recipient's lightning provider;
|
||||
// label the reply with the zap sender instead of the service key.
|
||||
val zapSender =
|
||||
if (parentNote.event is LnZapEvent) {
|
||||
observeZapSender(parentNote, accountViewModel).value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val parentAuthor = zapSender ?: parentNote.author
|
||||
if (parentAuthor != null) {
|
||||
ReplyToLabel(
|
||||
parentAuthorDisplay = parentAuthor.toBestDisplayName(),
|
||||
onClick = { nav.nav(routeFor(parentAuthor)) },
|
||||
)
|
||||
Spacer(modifier = HalfVertSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!makeItShort && noteEvent is CommentEvent) {
|
||||
// No in-cache parent note: this comment answers a scope rather than a note.
|
||||
DisplayCommentScope(noteEvent, accountViewModel, nav)
|
||||
}
|
||||
} else if (!makeItShort && noteEvent is CommentEvent) {
|
||||
// No in-cache parent note: this comment answers a scope rather than a note.
|
||||
DisplayCommentScope(noteEvent, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,29 +199,33 @@ fun RenderTextEvent(
|
||||
val tags =
|
||||
remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id =
|
||||
if (editState.value is GenericLoadable.Loaded) {
|
||||
(editState.value as GenericLoadable.Loaded<EditState>)
|
||||
.loaded.modificationToShow.value
|
||||
?.idHex ?: note.idHex
|
||||
} else {
|
||||
note.idHex
|
||||
},
|
||||
callbackUri = callbackUri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
TracedComposition(NoteTrace.TXT_RICHTEXT) {
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth().tracedDraw(NoteTrace.DRAW_RICHTEXT),
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id =
|
||||
if (editState.value is GenericLoadable.Loaded) {
|
||||
(editState.value as GenericLoadable.Loaded<EditState>)
|
||||
.loaded.modificationToShow.value
|
||||
?.idHex ?: note.idHex
|
||||
} else {
|
||||
note.idHex
|
||||
},
|
||||
callbackUri = callbackUri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (noteEvent.hasHashtags()) {
|
||||
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav)
|
||||
TracedComposition(NoteTrace.TXT_HASHTAGS) {
|
||||
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-20
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -43,6 +42,7 @@ import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -81,7 +81,9 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.LocalFeedReactionPainters
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.rememberFeedReactionPainters
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.NewGeoPostButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.NewHashtagPostButton
|
||||
@@ -423,24 +425,29 @@ fun FeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
if (liveSection != null) {
|
||||
item {
|
||||
DisplayLiveBubbles(liveSection, accountViewModel, nav)
|
||||
// One VectorPainter per reaction icon for the whole list rather than one per card. Provided
|
||||
// around the feed only, because a painter caches its raster by draw size and these icons appear
|
||||
// at other sizes on other screens. See FeedReactionPainters.
|
||||
val reactionPainters = rememberFeedReactionPainters()
|
||||
|
||||
CompositionLocalProvider(LocalFeedReactionPainters provides reactionPainters) {
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
if (liveSection != null) {
|
||||
item {
|
||||
DisplayLiveBubbles(liveSection, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { _, item ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
) {
|
||||
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { _, item ->
|
||||
// No wrapper Row: it held a single child that already fills the width, above a
|
||||
// custom single-pass Layout built to avoid extra nodes. NoteCompose puts this
|
||||
// modifier straight onto NoteComposeLayout (or onto BlankNote while the event
|
||||
// loads, which needs the fillMaxWidth to keep its width).
|
||||
NoteCompose(
|
||||
item,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().animateItem(),
|
||||
routeForLastRead = routeForLastRead,
|
||||
isBoostedNote = false,
|
||||
isHiddenFeed = items.showHidden,
|
||||
@@ -448,11 +455,11 @@ fun FeedLoaded(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.icons.symbols
|
||||
|
||||
/** Amethyst's own icons as font glyphs. See the build script for why. */
|
||||
object AmethystIcons {
|
||||
val Bookmark = MaterialSymbol("\uE900")
|
||||
val Like = MaterialSymbol("\uE901")
|
||||
val Liked = MaterialSymbol("\uE902")
|
||||
val Reply = MaterialSymbol("\uE903")
|
||||
val Repost = MaterialSymbol("\uE904")
|
||||
val Reposted = MaterialSymbol("\uE905")
|
||||
val Search = MaterialSymbol("\uE906")
|
||||
val Share = MaterialSymbol("\uE907")
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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.runtime.Composable
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
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
|
||||
|
||||
/**
|
||||
* Amethyst's own icons, built into a font by `tools/icon-font/build_icon_font.py`.
|
||||
*
|
||||
* Unlike Material Symbols this is a static font — no FILL/opsz/GRAD axes — so it needs no
|
||||
* [androidx.compose.ui.text.font.FontVariation] settings. Drawing an icon as a glyph blits from
|
||||
* the shared text atlas instead of rasterising an ImageVector's paths into a per-instance cached
|
||||
* layer, which is what made the feed re-rasterise the same three glyphs once per card.
|
||||
*/
|
||||
val LocalAmethystIconsFontFamily: ProvidableCompositionLocal<FontFamily?> = staticCompositionLocalOf { null }
|
||||
|
||||
@Composable
|
||||
fun amethystIconsFontFamily(): FontFamily =
|
||||
LocalAmethystIconsFontFamily.current ?: run {
|
||||
val font = Font(resource = Res.font.amethyst_icons)
|
||||
remember { FontFamily(font) }
|
||||
}
|
||||
+4
-1
@@ -43,8 +43,11 @@ import androidx.compose.ui.unit.LayoutDirection
|
||||
fun rememberMaterialSymbolPainter(
|
||||
symbol: MaterialSymbol,
|
||||
tint: Color = LocalContentColor.current,
|
||||
// Lets a caller draw from a different glyph font (e.g. Amethyst's own icon font) while
|
||||
// reusing this painter, its shared TextMeasurer and its caching.
|
||||
family: FontFamily? = null,
|
||||
): Painter {
|
||||
val fontFamily = materialSymbolsFontFamily()
|
||||
val fontFamily = family ?: materialSymbolsFontFamily()
|
||||
val textMeasurer = materialSymbolsTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
+50
-6
@@ -46,9 +46,11 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
|
||||
@@ -246,6 +248,33 @@ private fun HashTagText(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs that fully determine the width of a space. Every note in a feed shares the same ones.
|
||||
*/
|
||||
private data class SpaceWidthKey(
|
||||
val fontFamilyResolver: FontFamily.Resolver,
|
||||
val density: Float,
|
||||
val fontScale: Float,
|
||||
val layoutDirection: LayoutDirection,
|
||||
val textStyle: TextStyle,
|
||||
)
|
||||
|
||||
/**
|
||||
* Single-entry memo across composables.
|
||||
*
|
||||
* `remember` alone caches per call site, and every note card is its own call site — so a feed
|
||||
* scroll built a [TextMeasurer] and shaped a space glyph once per card to arrive at the same
|
||||
* number every time. Font resolution plus `Paragraph` construction is not free on a slow device,
|
||||
* and it lands squarely in the layout path.
|
||||
*
|
||||
* A one-entry memo is enough because a feed renders every note in the same style at the same
|
||||
* density; the key changes only on a theme, font-scale or locale-direction change, when
|
||||
* recomputing once is exactly right. The read/write race is benign: two threads racing produce
|
||||
* the same value, and the worst case is a redundant measure.
|
||||
*/
|
||||
private var spaceWidthKey: SpaceWidthKey? = null
|
||||
private var spaceWidthValue: Dp = 0.dp
|
||||
|
||||
/** Width of a single space in [textStyle], used to space FlowRow words. */
|
||||
@Composable
|
||||
fun measureSpaceWidth(textStyle: TextStyle): Dp {
|
||||
@@ -253,11 +282,26 @@ fun measureSpaceWidth(textStyle: TextStyle): Dp {
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
return remember(fontFamilyResolver, density, layoutDirection, textStyle) {
|
||||
val widthPx =
|
||||
TextMeasurer(fontFamilyResolver, density, layoutDirection, 1)
|
||||
.measure(" ", textStyle)
|
||||
.size
|
||||
.width
|
||||
with(density) { widthPx.toDp() }
|
||||
val key =
|
||||
SpaceWidthKey(
|
||||
fontFamilyResolver,
|
||||
density.density,
|
||||
density.fontScale,
|
||||
layoutDirection,
|
||||
textStyle,
|
||||
)
|
||||
if (key == spaceWidthKey) {
|
||||
spaceWidthValue
|
||||
} else {
|
||||
val widthPx =
|
||||
TextMeasurer(fontFamilyResolver, density, layoutDirection, 1)
|
||||
.measure(" ", textStyle)
|
||||
.size
|
||||
.width
|
||||
val width = with(density) { widthPx.toDp() }
|
||||
spaceWidthValue = width
|
||||
spaceWidthKey = key
|
||||
width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ coil = "3.5.0"
|
||||
composeBom = "2026.08.00"
|
||||
composeRuntimeAnnotation = "1.12.0"
|
||||
coreKtx = "1.19.0"
|
||||
tracing = "1.3.0"
|
||||
datastore = "1.2.1"
|
||||
devWhyolegCryptography = "0.6.0"
|
||||
espressoCore = "3.7.0"
|
||||
@@ -129,6 +130,7 @@ androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name
|
||||
androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" }
|
||||
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "androidxExifinterface" }
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-tracing = { group = "androidx.tracing", name = "tracing", version.ref = "tracing" }
|
||||
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" }
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# `:macrobenchmark` — feed rendering measurement rig
|
||||
|
||||
Measures what a feed scroll actually costs, on a real device, per sub-component.
|
||||
Built to study `NoteCompose`; kept because the traps below cost more time to find
|
||||
than the code did.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
ANDROID_SERIAL=<serial> ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \
|
||||
-Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true \
|
||||
-Pandroid.testInstrumentationRunnerArguments.class=\
|
||||
com.vitorpamplona.amethyst.macrobenchmark.FeedScrollBenchmark#scrollAttribution
|
||||
```
|
||||
|
||||
`leaveApksInstalledAfterRun` is **mandatory**. Without it AGP uninstalls the app when
|
||||
the run finishes, deleting its data — the logged-in account included. The next run then
|
||||
finds a login screen instead of a feed, having silently destroyed the state that made
|
||||
the previous run possible.
|
||||
|
||||
Tests: `scrollLive`, `scrollQuiet` (network cut once the feed fills), `scrollAttribution`
|
||||
(per-sub-component composition + draw), `scrollPhases` (frame phases and main-thread
|
||||
costs — `postAndWait`, `Compose:onForgotten`, `AndroidOwner:onTouch`, …).
|
||||
|
||||
## Methodology, learned the hard way
|
||||
|
||||
**A tight within-arm spread proves nothing about a between-arm delta.** The single most
|
||||
expensive mistake here. A result with a 0.9% baseline spread was still an artifact,
|
||||
because the two arms ran at different times against a *live* feed and rendered different
|
||||
notes. Always interleave arms (A,B,A,B) and always serve a frozen corpus —
|
||||
`tools/feed-bench-corpus/setup.sh`.
|
||||
|
||||
**Check the controls before the result.** Every ablation should leave some section it
|
||||
cannot possibly affect. If those move, the run is drift and the delta is unreadable.
|
||||
|
||||
**Verify the change is actually active.** A `CompositionLocal` that resolves to null, or
|
||||
a flag applied to the wrong one of six same-named `FeedLoaded` functions, makes a fix a
|
||||
silent no-op that looks exactly like "no effect". Emit a trace section from inside the
|
||||
new code path and assert a non-zero count.
|
||||
|
||||
**Per-composition section metrics need a synthetic corpus; anything involving engagement
|
||||
counts needs a real one.** Real notes have variable heights (images, `Show More`,
|
||||
reposts), so a fixed-distance scroll composes a different number of cards each run —
|
||||
section metrics swing 8–32% while frame metrics stay within 0.1%. Judge on frame metrics
|
||||
unless composition counts are identical across runs.
|
||||
|
||||
**Don't touch the device while a run is in flight.** Driving the UI mid-run killed one
|
||||
arm outright and corrupted another.
|
||||
|
||||
## What the numbers said (SM-T220, for orientation)
|
||||
|
||||
- The main thread is blocked in `postAndWait` on the RenderThread for roughly **two
|
||||
thirds of every frame**, and stayed within ~2% of that through six different
|
||||
ablations. Card composition is ~3% of frame CPU, so composition wins have a low
|
||||
ceiling: a −41% composition change moved frame P90 by 2.3%.
|
||||
- Overdraw *depth* is not the cost. The content area measured 4×+, and removing a
|
||||
full-screen fill changed frame time by nothing — while clearing it in the theme made
|
||||
the window non-opaque and measured ~17% **worse** at P90.
|
||||
- Still unexplained and worth a look: RenderThread `flush commands` at ~7.5 ms/frame,
|
||||
"Slow issue draw commands" on ~53% of frames, and tiny texture uploads costing
|
||||
absurdly much (a 360×17 texture at 37.6 ms).
|
||||
|
||||
## Release footprint — read before merging
|
||||
|
||||
The trace markers are gated on `BuildConfig.TRACE_NOTE_RENDER`, false outside the
|
||||
`benchmark` build type, so **they never execute** in debug or release. They are **not**
|
||||
stripped, however: the marker strings, `NoteRenderTrace`, `TracedComposition`,
|
||||
`ProbedCollect` and a reference to `androidx.tracing.Trace` are all present in the R8'd
|
||||
release DEX (verified by grepping it). Likely because `TracedComposition` is a
|
||||
`@Composable inline` function the Compose plugin transforms before R8 sees it.
|
||||
|
||||
Runtime cost is nil; APK footprint is not. Before merging this to `main`, move the
|
||||
tracer behind a source-set split (a no-op implementation for `debug`/`release`, the real
|
||||
one only in `benchmark`) so release genuinely contains none of it.
|
||||
|
||||
## The probes are broken on purpose
|
||||
|
||||
`PROBE_NO_FLOW_STATE` kills every live counter, `PROBE_NO_RX_ICONS` blanks the icons,
|
||||
`PROBE_NO_RX_CLICKABLE` makes buttons untappable. They exist to be measured and thrown
|
||||
away. All default to false.
|
||||
|
||||
## Normalize `Sum` metrics by their count — always
|
||||
|
||||
`TraceSectionMetric(Mode.Sum)` reports the summed duration of every matching slice
|
||||
in an iteration. That sum is comparable across arms **only if every iteration
|
||||
renders the same number of cards.** On a corpus of real notes it does not: card
|
||||
heights vary, so a fixed-distance swipe crosses a different number of cards each
|
||||
run. Three arms measured here — two of them running *identical code* — reported
|
||||
`NoteCardCount` of 10, 13 and 8.
|
||||
|
||||
The summed metrics therefore drifted 36–73% between identical arms, which is far
|
||||
larger than any effect worth shipping, and made a real result invisible. Dividing
|
||||
`<Section>SumSumMs` by `<Section>SumCount` removes the denominator:
|
||||
|
||||
```bash
|
||||
python3 macrobenchmark/tools/normalize.py path/to/*.json
|
||||
```
|
||||
|
||||
In the run that motivated this, that single step took `DrawAuthor` from an
|
||||
unreadable 41.8% apparent swing to a **4.6% drift floor with a clear +74% effect**.
|
||||
Read the per-occurrence table, never the raw sums.
|
||||
|
||||
## Warm the image cache before measuring
|
||||
|
||||
Freezing the *events* (`tools/feed-bench-corpus/setup.sh`) is not enough. Real
|
||||
notes carry remote image URLs that resolve asynchronously, so card heights keep
|
||||
moving until they land. The benchmark now scrolls the whole corpus once, with the
|
||||
network still up, before cutting the radios — see `WARMUP_SCROLLS`.
|
||||
|
||||
## Analysing a trace
|
||||
|
||||
`tools/rt_analyze.sh <trace.perfetto-trace>` decomposes a trace with Perfetto's
|
||||
`trace_processor`: RenderThread and main-thread **self**-time by slice (summing by
|
||||
name across depths double-counts), texture/atlas uploads, and the children of the
|
||||
`animation` slice. Prefer this to parsing `atrace` text — text parsing is what
|
||||
previously credited macrobenchmark's own `reportMetricsWithPresentTime` to the app.
|
||||
|
||||
## Use the uniform corpus (`tools/feed-bench-corpus/seed-uniform.sh`)
|
||||
|
||||
The real-event corpus cannot give comparable arms. The app persists no events, so every
|
||||
launch re-downloads and renders a *different subset* in a different order; two cold
|
||||
launches shared only half their visible notes, and a longer settle made it worse. Combined
|
||||
with notes of wildly different heights (only 28 of 105 real notes are text-only, spanning
|
||||
11-510 chars), a fixed-distance scroll crosses a different number of cards every arm.
|
||||
|
||||
`seed-uniform.sh` serves notes that are all the *same height*, so the scroll crosses the
|
||||
same number of cards no matter which notes loaded. Measured effect on two arms of
|
||||
identical code:
|
||||
|
||||
| | real corpus | uniform corpus |
|
||||
|---|---|---|
|
||||
| NoteCardSumCount | 14 vs 17 | **18 vs 18** |
|
||||
| frameDurationCpuMs P90 drift | 10.1% | **1.4%** |
|
||||
| frameOverrunMs P90 drift | 20.0% | **2.8%** |
|
||||
|
||||
Requirements, each learned the hard way — see the script's comments:
|
||||
|
||||
- Bodies must be unique but the same length; byte-identical content is collapsed by the
|
||||
app's duplicate/spam filter (60 identical notes rendered as ONE card).
|
||||
- Every note needs nonzero reactions, or the animation under test never constructs.
|
||||
- No kind-6 reposts: they are their own card shape and, without the reposted event inline
|
||||
(NIP-18), render "Event is loading or can't be found in your relay list".
|
||||
- All authors share one `picture` URL. Warm it once with the network up, then run offline:
|
||||
one cached bitmap, identical avatar path per card, and the real image path still runs.
|
||||
|
||||
Run the app **offline** (the benchmark cuts the radios before launch; the corpus arrives
|
||||
over `adb reverse`, which is USB and unaffected), and set the bench account's Tor engine
|
||||
to **Off** or a blocking "Tor isn't connecting" dialog swallows every swipe.
|
||||
@@ -0,0 +1,56 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.test")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.vitorpamplona.amethyst.macrobenchmark"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
// Macrobenchmark needs API 29+; frame metrics from `dumpsys gfxinfo` need 29+.
|
||||
minSdk = 29
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// The SM-T220 and the Pixel 9 AVD are both legitimate targets here: the emulator
|
||||
// is suppressed from erroring out, and the tablet is often not fully charged.
|
||||
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] =
|
||||
"EMULATOR,LOW-BATTERY,DEBUGGABLE,NOT-PROFILEABLE,ENG-BUILD"
|
||||
|
||||
// :amethyst has a `channel` dimension (play/fdroid). Rendering does not differ by
|
||||
// store, so measure against play.
|
||||
missingDimensionStrategy("channel", "play")
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
// Run against the profileable, R8-optimized `benchmark` variant of the app. Measuring
|
||||
// a debug build tells you about ART's interpreter, not about the app's own code.
|
||||
buildTypes {
|
||||
create("benchmark") {
|
||||
isDebuggable = true
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
targetProjectPath = ":amethyst"
|
||||
experimentalProperties["android.experimental.self-instrumenting"] = true
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.junit)
|
||||
implementation(libs.androidx.runner)
|
||||
implementation(libs.androidx.uiautomator)
|
||||
implementation(libs.androidx.benchmark.macro.junit4)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Macrobenchmark drives the app from a separate, self-instrumenting process. -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
</manifest>
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* 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.macrobenchmark
|
||||
|
||||
import androidx.benchmark.macro.CompilationMode
|
||||
import androidx.benchmark.macro.ExperimentalMetricApi
|
||||
import androidx.benchmark.macro.FrameTimingMetric
|
||||
import androidx.benchmark.macro.Metric
|
||||
import androidx.benchmark.macro.TraceSectionMetric
|
||||
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Frame-timing of the home feed scroll — i.e. of [NoteCompose] rendering, since the feed's
|
||||
* `LazyColumn` item body is one `NoteCompose` per note.
|
||||
*
|
||||
* Two variants, because the feed's frame budget is contended by two very different things:
|
||||
*
|
||||
* - [scrollLive] scrolls while the relay pool is still delivering. This is what a user
|
||||
* actually experiences, but the number is a mix of rendering cost and the ingest storm
|
||||
* (relay parse + `LocalCache` writes + the GC pressure they create) stealing CPU from
|
||||
* the main thread.
|
||||
* - [scrollQuiet] cuts the network *after* the feed has filled, so ingest stops and the
|
||||
* same already-cached notes are re-rendered. That isolates the composition/layout/draw
|
||||
* cost of the card itself.
|
||||
*
|
||||
* The gap between the two is the ingest interference; the [scrollQuiet] number is the
|
||||
* ceiling that rendering work alone can move.
|
||||
*
|
||||
* ## Running
|
||||
*
|
||||
* ```
|
||||
* ANDROID_SERIAL=<serial> ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \
|
||||
* -Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true
|
||||
* ```
|
||||
*
|
||||
* **The `leaveApksInstalledAfterRun` flag is mandatory, not optional.** Without it AGP
|
||||
* uninstalls the target APK when the run finishes, which deletes the app's data — the
|
||||
* logged-in account and the selected feed included. The next run then launches a
|
||||
* first-boot app, finds a login screen instead of a feed, and fails in `setupBlock`
|
||||
* (having silently destroyed the account that made the previous run possible).
|
||||
*
|
||||
* The device must already be logged in and sitting on a feed with content. A throwaway
|
||||
* account on the **Global** feed is the reproducible choice: it needs no follows, so the
|
||||
* same content type is available on every device.
|
||||
*/
|
||||
@OptIn(ExperimentalMetricApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FeedScrollBenchmark {
|
||||
@get:Rule
|
||||
val rule = MacrobenchmarkRule()
|
||||
|
||||
@Test
|
||||
fun scrollLive() = scrollFeed(quiet = false)
|
||||
|
||||
@Test
|
||||
fun scrollQuiet() = scrollFeed(quiet = true)
|
||||
|
||||
/**
|
||||
* Same quiet scroll, but reporting the per-sub-component composition time emitted by
|
||||
* `TracedComposition` in the app. Requires the `benchmark` build type (which sets
|
||||
* `TRACE_NOTE_RENDER = true`); on any other build the sections are absent and every
|
||||
* value reads zero.
|
||||
*
|
||||
* `Mode.Sum` is the number that matters: total ms spent composing that part of the
|
||||
* card across the whole scroll, which is what a code change has to move.
|
||||
*/
|
||||
@Test
|
||||
fun scrollAttribution() = scrollFeed(quiet = true, metrics = frameMetrics() + sectionMetrics())
|
||||
|
||||
/**
|
||||
* Splits a frame into its phases rather than its composables.
|
||||
*
|
||||
* The card's composition is only a few percent of frame CPU on a slow device, so the rest has to
|
||||
* be somewhere the `TracedComposition` markers cannot see: the measure/layout/draw traversal.
|
||||
* These sections are emitted by `ViewRootImpl` and Compose UI themselves — no app instrumentation
|
||||
* — so this works on any build.
|
||||
*
|
||||
* `Mode.Sum` per iteration is the useful figure: total ms the scroll spent in each phase.
|
||||
*/
|
||||
@Test
|
||||
fun scrollPhases() = scrollFeed(quiet = true, metrics = frameMetrics() + phaseMetrics())
|
||||
|
||||
private fun frameMetrics(): List<Metric> = listOf(FrameTimingMetric())
|
||||
|
||||
private fun sectionMetrics(): List<Metric> =
|
||||
TRACED_SECTIONS.flatMap { section ->
|
||||
listOf(
|
||||
TraceSectionMetric(section, TraceSectionMetric.Mode.Sum, label = "${section.removePrefix("Amethyst:")}Sum"),
|
||||
TraceSectionMetric(section, TraceSectionMetric.Mode.Count, label = "${section.removePrefix("Amethyst:")}Count"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun phaseMetrics(): List<Metric> =
|
||||
FRAME_PHASES.flatMap { section ->
|
||||
listOf(
|
||||
TraceSectionMetric(section, TraceSectionMetric.Mode.Sum, label = "${section.replace(':', '_').replace('#', '_')}Sum"),
|
||||
TraceSectionMetric(section, TraceSectionMetric.Mode.Count, label = "${section.replace(':', '_').replace('#', '_')}Count"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun scrollFeed(
|
||||
quiet: Boolean,
|
||||
metrics: List<Metric> = frameMetrics(),
|
||||
) {
|
||||
var prepared = false
|
||||
|
||||
try {
|
||||
rule.measureRepeated(
|
||||
packageName = PACKAGE,
|
||||
metrics = metrics,
|
||||
// Full AOT, deliberately: this study compares *rendering* costs across code
|
||||
// changes, and JIT warm-up variance would swamp the differences being looked
|
||||
// for. Absolute numbers therefore read slightly better than a shipped build.
|
||||
compilationMode = CompilationMode.Full(),
|
||||
iterations = ITERATIONS,
|
||||
setupBlock = {
|
||||
if (!prepared) {
|
||||
// Cut the radios BEFORE the app launches, not after the feed loads.
|
||||
//
|
||||
// The corpus relay is reached over `adb reverse` (USB loopback), which
|
||||
// is unaffected by wifi/data, so the app still ingests the fixed corpus
|
||||
// -- and *only* the corpus. Cutting the network after the settle, as
|
||||
// this used to, left the app free to pull live notes from ~190 real
|
||||
// relays while the feed was being built. Those differ on every launch,
|
||||
// which is why each arm was internally deterministic (identical card
|
||||
// counts across all 10 iterations) yet landed on a different constant
|
||||
// from its neighbours -- 8 vs 12 vs 13 NoteCards -- making three A/B
|
||||
// runs unreadable.
|
||||
if (quiet) setNetworkEnabled(false)
|
||||
pressHome()
|
||||
startActivityAndWait()
|
||||
// Let relays connect and fill the feed. Everything measured afterwards
|
||||
// re-renders notes that are already in LocalCache.
|
||||
device.wait(Until.hasObject(By.scrollable(true)), FEED_APPEAR_TIMEOUT_MS)
|
||||
check(device.hasObject(By.scrollable(true))) {
|
||||
"No scrollable feed appeared — is an account logged in with a non-empty feed?"
|
||||
}
|
||||
Thread.sleep(INGEST_SETTLE_MS)
|
||||
|
||||
// Warm the image cache before measuring anything. A corpus of real notes
|
||||
// carries remote image URLs, and those resolve asynchronously: until they
|
||||
// do, card heights keep changing, so a fixed-distance scroll composes a
|
||||
// different number of cards on every run. That alone widened section
|
||||
// spreads from ~1-3% to 17-28% and made an A/B unreadable. Scrolling the
|
||||
// whole corpus once, with the network still up, settles every height and
|
||||
// fills Coil's cache; only then is it safe to cut the network.
|
||||
repeat(WARMUP_SCROLLS) { swipeFeed(down = true) }
|
||||
device.waitForIdle()
|
||||
repeat(WARMUP_SCROLLS) { swipeFeed(down = false) }
|
||||
device.waitForIdle()
|
||||
Thread.sleep(IMAGE_SETTLE_MS)
|
||||
|
||||
prepared = true
|
||||
}
|
||||
// Every iteration starts from the same place in the feed, so each one
|
||||
// renders a comparable set of cards.
|
||||
repeat(SCROLLS) { swipeFeed(down = false) }
|
||||
device.waitForIdle()
|
||||
},
|
||||
) {
|
||||
repeat(SCROLLS) { swipeFeed(down = true) }
|
||||
device.waitForIdle()
|
||||
}
|
||||
} finally {
|
||||
// Restore the radios even when an iteration throws, so a failed run does not
|
||||
// leave the device offline for the next one.
|
||||
if (quiet) setNetworkEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
private val device: UiDevice
|
||||
get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
|
||||
/**
|
||||
* Drags the feed by a fixed fraction of the screen using raw coordinates.
|
||||
*
|
||||
* Deliberately not `UiObject2.scroll()`: that resolves an accessibility node up front
|
||||
* and then throws [androidx.test.uiautomator.StaleObjectException] the moment the list
|
||||
* recycles it mid-gesture, which a scrolling feed does constantly. Raw coordinates also
|
||||
* make the gesture identical on every run and every device (same fraction of the
|
||||
* screen, same step count), which is what makes iterations comparable.
|
||||
*
|
||||
* [SWIPE_STEPS] is high on purpose: many small steps produce a steady drag rather than
|
||||
* a fling, so the measured frames are the ones that actually compose new cards instead
|
||||
* of a short burst followed by decelerating scroll.
|
||||
*/
|
||||
private fun swipeFeed(down: Boolean) {
|
||||
val x = device.displayWidth / 2
|
||||
val top = (device.displayHeight * SWIPE_TOP_FRACTION).toInt()
|
||||
val bottom = (device.displayHeight * SWIPE_BOTTOM_FRACTION).toInt()
|
||||
if (down) {
|
||||
device.swipe(x, bottom, x, top, SWIPE_STEPS)
|
||||
} else {
|
||||
device.swipe(x, top, x, bottom, SWIPE_STEPS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setNetworkEnabled(enabled: Boolean) {
|
||||
val state = if (enabled) "enable" else "disable"
|
||||
device.executeShellCommand("svc wifi $state")
|
||||
device.executeShellCommand("svc data $state")
|
||||
// The radios take a moment to actually go down; without this the first measured
|
||||
// iteration still sees in-flight relay traffic.
|
||||
Thread.sleep(NETWORK_TOGGLE_SETTLE_MS)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PACKAGE = "com.vitorpamplona.amethyst.benchmark"
|
||||
|
||||
/** Enough iterations for a stable median without pushing the tablet into an lmkd kill. */
|
||||
const val ITERATIONS = 10
|
||||
|
||||
const val SCROLLS = 4
|
||||
|
||||
/** Gesture spans the middle 60% of the screen, clear of the status and nav bars. */
|
||||
const val SWIPE_TOP_FRACTION = 0.2
|
||||
const val SWIPE_BOTTOM_FRACTION = 0.8
|
||||
|
||||
/** ~5 ms per step, so one swipe is a ~300 ms steady drag rather than a fling. */
|
||||
const val SWIPE_STEPS = 60
|
||||
|
||||
const val FEED_APPEAR_TIMEOUT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* Long enough for relays to deliver a screenful of notes, short enough to stay under
|
||||
* the memory ceiling of a 3 GB SM-T220, which lmkd-kills a release build around 1.85 GB.
|
||||
*/
|
||||
const val INGEST_SETTLE_MS = 20_000L
|
||||
|
||||
const val NETWORK_TOGGLE_SETTLE_MS = 3_000L
|
||||
|
||||
/** Enough passes to touch every card in the corpus so all images resolve. */
|
||||
const val WARMUP_SCROLLS = 24
|
||||
|
||||
/** Lets the last decodes land after the warm-up gesture stops. */
|
||||
const val IMAGE_SETTLE_MS = 8_000L
|
||||
|
||||
/**
|
||||
* Frame-phase sections emitted by the platform and by Compose UI itself. Nothing in the app
|
||||
* has to be instrumented for these; absent sections simply report zero.
|
||||
*/
|
||||
val FRAME_PHASES =
|
||||
listOf(
|
||||
"Choreographer#doFrame",
|
||||
"traversal",
|
||||
"measure",
|
||||
"layout",
|
||||
"draw",
|
||||
"AndroidOwner:measureAndLayout",
|
||||
"Compose:recompose",
|
||||
"Compose:applyChanges",
|
||||
// RenderThread: DrawFrame executes the display list the UI thread recorded, and is
|
||||
// where the other half of frame CPU lives on a draw-bound device.
|
||||
"DrawFrame",
|
||||
"syncFrameState",
|
||||
"flush commands",
|
||||
"eglSwapBuffersWithDamageKHR",
|
||||
// Main-thread costs an atrace capture showed dominating a scroll, none of which
|
||||
// the composition/draw markers can see.
|
||||
"postAndWait",
|
||||
"Compose:onForgotten",
|
||||
"Compose:onRemembered",
|
||||
"AndroidOwner:onTouch",
|
||||
"TextStringSimpleNode::measure",
|
||||
"TextAnnotatedStringNode:measure",
|
||||
"TextLayout:initLayout",
|
||||
"animation",
|
||||
"Amethyst:DrawAuthor",
|
||||
)
|
||||
|
||||
/** Must stay in sync with `NoteTrace` in the app. */
|
||||
val TRACED_SECTIONS =
|
||||
listOf(
|
||||
"Amethyst:NoteCard",
|
||||
"Amethyst:AuthorImages",
|
||||
"Amethyst:FirstUserInfoRow",
|
||||
"Amethyst:SecondUserInfoRow",
|
||||
"Amethyst:NoteContent",
|
||||
"Amethyst:ReactionsRow",
|
||||
// Drill-down inside the two most expensive slots.
|
||||
"Amethyst:RxIndicators",
|
||||
"Amethyst:RxZapraiser",
|
||||
"Amethyst:RxReply",
|
||||
"Amethyst:RxBoost",
|
||||
"Amethyst:RxLike",
|
||||
"Amethyst:RxZap",
|
||||
"Amethyst:RxShare",
|
||||
"Amethyst:RxPay",
|
||||
"Amethyst:TxtReplyPreview",
|
||||
"Amethyst:TxtRichText",
|
||||
"Amethyst:TxtHashtags",
|
||||
// Draw phase (Modifier.tracedDraw).
|
||||
"Amethyst:DrawReactions",
|
||||
"Amethyst:DrawAuthor",
|
||||
"Amethyst:DrawFirstRow",
|
||||
"Amethyst:DrawRichText",
|
||||
// Non-zero count proves the shared-painter path is actually being taken.
|
||||
"Amethyst:SharedPainter",
|
||||
)
|
||||
}
|
||||
}
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare macrobenchmark arms by *per-occurrence* section time.
|
||||
|
||||
A TraceSectionMetric in Mode.Sum reports the summed duration of every matching
|
||||
slice in an iteration. That sum is only comparable across arms if each iteration
|
||||
renders the same number of cards -- and on a corpus of real notes it does not:
|
||||
card heights vary, so a fixed-distance swipe crosses a different number of cards
|
||||
every run. Measured counts ranged 8..13 for NoteCard in three arms of identical
|
||||
code, which alone moved the summed metrics by 36-73%.
|
||||
|
||||
Dividing SumSumMs by SumCount removes that denominator. In the run that motivated
|
||||
this script it took DrawAuthor from unreadable (41.8% apparent swing) to a 4.6%
|
||||
drift floor with a clear +74% effect.
|
||||
|
||||
Usage: normalize.py armA.json armB.json [...] (arm name = file stem)
|
||||
"""
|
||||
import json, sys
|
||||
|
||||
def load(path):
|
||||
b = json.load(open(path))["benchmarks"][0]
|
||||
out = {}
|
||||
for src in ("metrics", "sampledMetrics"):
|
||||
for k, v in b.get(src, {}).items():
|
||||
if isinstance(v, dict) and "median" in v:
|
||||
out[k] = v["median"]
|
||||
return out
|
||||
|
||||
def main(paths):
|
||||
arms = {p.split("/")[-1].removesuffix(".json"): load(p) for p in paths}
|
||||
names = list(arms)
|
||||
secs = sorted({k[:-8] for k in arms[names[0]] if k.endswith("SumSumMs")})
|
||||
print(f"{'section (ms per occurrence)':<26}" + "".join(f"{n:>10}" for n in names))
|
||||
print("-" * (26 + 10 * len(names)))
|
||||
for s in secs:
|
||||
vals = []
|
||||
for n in names:
|
||||
ms, cnt = arms[n].get(s + "SumSumMs"), arms[n].get(s + "SumCount")
|
||||
vals.append(ms / cnt if ms is not None and cnt else None)
|
||||
if any(v is None for v in vals):
|
||||
continue
|
||||
print(f"{s:<26}" + "".join(f"{v:>10.3f}" for v in vals))
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
main(sys.argv[1:])
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# Decompose a macrobenchmark perfetto trace. $1 = trace file.
|
||||
TP=/private/tmp/claude-501/-Users-vitor-Documents-workspace-Amethyst/c1db9f48-dfa5-437b-acc2-a33fcb5fe512/scratchpad/trace_processor
|
||||
T="$1"
|
||||
echo "############ RenderThread: self-time by slice name ############"
|
||||
$TP -Q "
|
||||
with rt as (
|
||||
select s.id, s.name, s.dur, s.parent_id
|
||||
from slice s join thread_track tt on s.track_id=tt.id join thread t using(utid)
|
||||
where t.name like '%RenderThread%'
|
||||
),
|
||||
child as (select parent_id, sum(dur) d from rt where parent_id is not null group by parent_id)
|
||||
select rt.name, count(*) n, round(sum(rt.dur - coalesce(child.d,0))/1e6,1) self_ms,
|
||||
round(sum(rt.dur)/1e6,1) total_ms
|
||||
from rt left join child on child.parent_id = rt.id
|
||||
group by rt.name order by self_ms desc limit 25;" "$T" 2>/dev/null
|
||||
|
||||
echo "############ Texture / atlas uploads (name + dimensions) ############"
|
||||
$TP -Q "
|
||||
select s.name, count(*) n, round(sum(s.dur)/1e6,2) ms, round(avg(s.dur)/1e3,1) avg_us
|
||||
from slice s join thread_track tt on s.track_id=tt.id join thread t using(utid)
|
||||
where t.name like '%RenderThread%'
|
||||
and (s.name like '%upload%' or s.name like '%Texture%' or s.name like '%Atlas%'
|
||||
or s.name like '%glyph%' or s.name like '%Glyph%')
|
||||
group by s.name order by ms desc limit 30;" "$T" 2>/dev/null
|
||||
|
||||
echo "############ Main thread: self-time by slice name ############"
|
||||
$TP -Q "
|
||||
with mt as (
|
||||
select s.id, s.name, s.dur, s.parent_id
|
||||
from slice s join thread_track tt on s.track_id=tt.id join thread t using(utid)
|
||||
where t.is_main_thread = 1
|
||||
),
|
||||
child as (select parent_id, sum(dur) d from mt where parent_id is not null group by parent_id)
|
||||
select mt.name, count(*) n, round(sum(mt.dur - coalesce(child.d,0))/1e6,1) self_ms
|
||||
from mt left join child on child.parent_id = mt.id
|
||||
group by mt.name order by self_ms desc limit 25;" "$T" 2>/dev/null
|
||||
|
||||
echo "############ Children of 'animation' (the 837ms mystery) ############"
|
||||
$TP -Q "
|
||||
select c.name, count(*) n, round(sum(c.dur)/1e6,1) ms
|
||||
from slice p join slice c on c.parent_id = p.id
|
||||
where p.name = 'animation'
|
||||
group by c.name order by ms desc limit 20;" "$T" 2>/dev/null
|
||||
@@ -34,6 +34,7 @@ include(":amethyst")
|
||||
include(":nappletHost")
|
||||
include(":benchmark")
|
||||
include(":baselineprofile")
|
||||
include(":macrobenchmark")
|
||||
include(":quartz")
|
||||
include(":geode")
|
||||
include(":commons")
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Uniform corpus: every card identical in structure so a fixed-distance scroll
|
||||
# crosses the same number of cards regardless of which notes loaded or in what
|
||||
# order. That is what the real corpus could not give us -- the app persists no
|
||||
# events, so each launch re-downloads and renders a different subset.
|
||||
#
|
||||
# Deliberately NOT zero-reaction: the previous synthetic corpus pinned heights but
|
||||
# left every count at 0, so SlidingAnimationCount rendered nothing and the code
|
||||
# under test never constructed. Every note here gets identical real reactions.
|
||||
set -uo pipefail
|
||||
ROOT=/Users/vitor/Documents/workspace/Amethyst
|
||||
SP=/private/tmp/claude-501/-Users-vitor-Documents-workspace-Amethyst/c1db9f48-dfa5-437b-acc2-a33fcb5fe512/scratchpad
|
||||
AMY="$ROOT/cli/build/install/amy/bin/amy"
|
||||
DB="$SP/uniform.db"
|
||||
RELAY="ws://127.0.0.1:7447"
|
||||
NOTES=${NOTES:-60}
|
||||
# Notes must be RECENT. The home feed only surfaces recent notes: seeded at a
|
||||
# fixed 2025 timestamp, 59 of 60 notes never appeared and the feed rendered a
|
||||
# single card. Spread them over the last hour instead.
|
||||
NOW=$(date +%s)
|
||||
BASE=$((NOW - 3600))
|
||||
amy() { HOME="$SP/amyhome" "$AMY" "$@"; }
|
||||
|
||||
# same picture for every author => one cached bitmap, identical avatar path per card
|
||||
PIC="https://robohash.org/benchshared.png"
|
||||
# Each body must be UNIQUE but the SAME LENGTH. Byte-identical content is
|
||||
# collapsed by the app's duplicate/spam filter -- 60 identical notes rendered as
|
||||
# exactly ONE card. A fixed-width numeric suffix keeps every card the same height
|
||||
# while making the content distinct.
|
||||
BODY="alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango"
|
||||
|
||||
# Kill whatever holds the port, not just the relay we expect: a previous run can
|
||||
# leave a relay bound to 7447 serving a different db, and then every publish
|
||||
# silently goes nowhere (the symptom is "FAILED to parse id at note 0").
|
||||
echo "stopping any relay on 7447…"
|
||||
for pid in $(lsof -tiTCP:7447 -sTCP:LISTEN 2>/dev/null); do kill "$pid" 2>/dev/null; done
|
||||
sleep 4
|
||||
if lsof -tiTCP:7447 -sTCP:LISTEN >/dev/null 2>&1; then echo "port 7447 still busy, aborting"; exit 1; fi
|
||||
rm -f "$DB"*
|
||||
echo "starting relay on $RELAY (db: uniform.db)"
|
||||
amy --account bench1 serve --db "$DB" --port 7447 > "$SP/uniform-relay.log" 2>&1 &
|
||||
echo $! > "$SP/uniform-relay.pid"; sleep 10
|
||||
|
||||
declare -a PK
|
||||
for n in 1 2 3 4 5; do
|
||||
PK[$n]=$(amy --account "bench$n" whoami 2>/dev/null | awk '/^hex:/{print $2}')
|
||||
amy --account "bench$n" event --kind 0 --created-at $((BASE - 100 + n)) \
|
||||
--content "{\"name\":\"Bench $n\",\"picture\":\"$PIC\"}" --relay "$RELAY" >/dev/null 2>&1
|
||||
done
|
||||
echo "authors seeded: ${PK[1]:0:8}… ${PK[5]:0:8}…"
|
||||
|
||||
for i in $(seq 0 $((NOTES-1))); do
|
||||
a=$(( i % 5 + 1 ))
|
||||
out=$(amy --account "bench$a" event --kind 1 --created-at $((BASE + i*50)) \
|
||||
--content "$BODY $(printf '%04d' $i)" --relay "$RELAY" 2>/dev/null)
|
||||
id=$(printf '%s' "$out" | grep -oE '[0-9a-f]{64}' | head -1)
|
||||
[ -z "$id" ] && { echo "FAILED to parse id at note $i"; printf '%s\n' "$out" | head -5; exit 1; }
|
||||
# identical engagement on every note: 2 likes + 1 repost
|
||||
for r in 1 2; do
|
||||
b=$(( (i + r) % 5 + 1 ))
|
||||
# fixed old timestamp: engagement must not reorder the feed or create
|
||||
# "now" cards at the top on every seeding run
|
||||
amy --account "bench$b" event --kind 7 --content "+" --created-at $((BASE + i*50 - 10*r)) \
|
||||
--tags "[[\"e\",\"$id\"],[\"p\",\"${PK[$a]}\"]]" --relay "$RELAY" >/dev/null 2>&1
|
||||
done
|
||||
# NO kind-6 reposts. A repost is its own feed entry with a different card shape,
|
||||
# and with an empty content it renders "Event is loading or can't be found in
|
||||
# your relay list" (NIP-18 wants the reposted event JSON inline). Either way it
|
||||
# breaks the constant-height property this corpus exists to provide. Cost: boost
|
||||
# counts stay 0, so only the like counter exercises SlidingAnimationCount.
|
||||
[ $((i % 10)) -eq 0 ] && echo " note $i/$NOTES"
|
||||
done
|
||||
echo "DONE: $(amy --account bench1 count --kind 1 --relay "$RELAY" --timeout 15 2>/dev/null | awk '/total/{print $2}') notes"
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fixed-corpus relay rig for feed rendering benchmarks.
|
||||
#
|
||||
# Why: FeedScrollBenchmark's numbers are only comparable if every run renders the
|
||||
# same notes. Against a live Global feed they do not — measured drift between two
|
||||
# identical baseline runs reached 73-97% on avatar metrics and moved DrawReactions
|
||||
# from 2.78 to 1.70 with no code change, because card heights (how many cards a
|
||||
# fixed-distance scroll composes) and the pictureless-author mix (robohash vectors
|
||||
# are expensive to draw) both follow whatever the firehose happened to deliver.
|
||||
#
|
||||
# This serves a frozen corpus instead. NOTE: a *synthetic* corpus (uniform generated
|
||||
# notes) pins card heights beautifully but is useless for anything involving reaction
|
||||
# counts — with every count at zero, SlidingAnimationCount renders nothing and the
|
||||
# AnimatedContent under test is never constructed. Capture real events instead:
|
||||
#
|
||||
# amy --account benchN fetch --kind 7,6,9735 --limit 600 --relay <real relays> --json
|
||||
# -> collect the e-tagged note ids -> fetch those notes -> fetch their kind 0
|
||||
# -> republish all of it into the local relay
|
||||
#
|
||||
# Reactions first, then the notes they point at: notes fetched directly are usually
|
||||
# too fresh to have any engagement.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
WORK="${WORK:-/tmp/amethyst-feed-corpus}"
|
||||
AMY="$ROOT/cli/build/install/amy/bin/amy"
|
||||
RELAY="ws://127.0.0.1:7447"
|
||||
PORT=7447
|
||||
|
||||
mkdir -p "$WORK"
|
||||
export HOME_ORIG="$HOME"
|
||||
amy() { HOME="$WORK/amyhome" "$AMY" "$@"; }
|
||||
|
||||
if [ ! -x "$AMY" ]; then
|
||||
echo "building amy…"; (cd "$ROOT" && ./gradlew -q :cli:installDist)
|
||||
fi
|
||||
|
||||
# Throwaway identities, isolated from the developer's real ~/.amy, with the
|
||||
# plaintext backend so nothing prompts the macOS keychain.
|
||||
if [ ! -d "$WORK/amyhome" ]; then
|
||||
mkdir -p "$WORK/amyhome"
|
||||
for n in 1 2 3 4 5; do
|
||||
amy --account "bench$n" --secret-backend plaintext init >/dev/null
|
||||
done
|
||||
fi
|
||||
|
||||
echo "starting relay on $RELAY (db: $WORK/corpus.db)"
|
||||
amy --account bench1 serve --db "$WORK/corpus.db" --port "$PORT" > "$WORK/relay.log" 2>&1 &
|
||||
echo $! > "$WORK/relay.pid"
|
||||
sleep 10
|
||||
|
||||
if [ ! -f "$WORK/.seeded" ]; then
|
||||
echo "seeding corpus…"
|
||||
# Three authors with pictures, two without: pins how many avatars fall back to a
|
||||
# generated robohash, which is the dominant swing in DrawAuthor.
|
||||
for n in 1 2 3; do
|
||||
amy --account "bench$n" event --kind 0 --created-at "$((1750000000 + n))" \
|
||||
--content "{\"name\":\"Bench $n\",\"picture\":\"https://robohash.org/bench$n.png\"}" \
|
||||
--relay "$RELAY" >/dev/null
|
||||
done
|
||||
for n in 4 5; do
|
||||
amy --account "bench$n" event --kind 0 --created-at "$((1750000000 + n))" \
|
||||
--content "{\"name\":\"Bench $n\"}" --relay "$RELAY" >/dev/null
|
||||
done
|
||||
|
||||
python3 - "$WORK" <<'PY' > "$WORK/notes.sh"
|
||||
import sys
|
||||
WORDS = ("alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima "
|
||||
"mike november oscar papa quebec romeo sierra tango uniform victor whiskey").split()
|
||||
LENGTHS = [6, 40, 12, 90, 20, 5, 55, 15, 120, 30] # fixed cycle => fixed card heights
|
||||
for i in range(80):
|
||||
n = LENGTHS[i % len(LENGTHS)]
|
||||
body = " ".join(WORDS[(i + j) % len(WORDS)] for j in range(n))
|
||||
print(f"amy --account bench{i % 5 + 1} event --kind 1 --created-at {1751000000 + i} "
|
||||
f"--content '{body}' --relay $RELAY >/dev/null")
|
||||
PY
|
||||
. "$WORK/notes.sh"
|
||||
touch "$WORK/.seeded"
|
||||
fi
|
||||
|
||||
echo "corpus: $(amy --account bench1 count --kind 1 --relay "$RELAY" --timeout 15 | awk '/total/{print $2}') notes"
|
||||
|
||||
for SERIAL in $(adb devices | awk '/device$/{print $1}'); do
|
||||
adb -s "$SERIAL" reverse tcp:$PORT tcp:$PORT && echo "adb reverse ready on $SERIAL"
|
||||
done
|
||||
|
||||
cat <<'EOF'
|
||||
|
||||
Final step (once per device, by hand):
|
||||
|
||||
Amethyst > drawer > Relays > "Local Relays" -> add ws://127.0.0.1:7447 > Save
|
||||
|
||||
It MUST go in the "Local Relays" section ("relays running on this device").
|
||||
That list is device-local. Adding the relay to "Public Outbox/Home Relays"
|
||||
instead does not stick: the app re-fetches the account's kind-10002 from the
|
||||
network and a loopback URL does not survive the round trip, so the entry silently
|
||||
disappears within a minute. `Account.outboxHomeRelays()` unions nip65 +
|
||||
privateStorage + localRelayList, so a Local Relay is used for reading all the
|
||||
same.
|
||||
|
||||
Two automation traps if you try to script this:
|
||||
- The nav drawer and the relay settings are overlay windows `uiautomator dump`
|
||||
does not capture; it returns the feed behind them, so taps look like no-ops.
|
||||
Drive it from screenshots.
|
||||
- The relay settings screen nests a scrollable relay list that swallows
|
||||
flings, so the outer screen stops scrolling partway down.
|
||||
|
||||
Then the feed contains exactly the 80 corpus notes, identical on every run.
|
||||
Stop the relay with: kill $(cat "$WORK/relay.pid")
|
||||
EOF
|
||||
@@ -0,0 +1,46 @@
|
||||
# Amethyst icon font
|
||||
|
||||
Builds `amethyst_icons.ttf` from the Kotlin `ImageVector` icons in
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/`.
|
||||
|
||||
## Why
|
||||
|
||||
`Icon(imageVector = …)` calls `rememberVectorPainter`, and a `VectorPainter`
|
||||
rasterises its paths into a cached graphics layer **per instance**. A feed therefore
|
||||
re-rasterised the same handful of glyphs once for every card scrolled in. A font glyph
|
||||
is a blit from the shared text atlas instead, shared across every call site in the app
|
||||
for free — no `CompositionLocal` plumbing, no per-screen scoping.
|
||||
|
||||
Measured on the uniform-corpus macrobenchmark (SM-T220, three arms, 0.2% noise floor):
|
||||
|
||||
| approach | frame P90 | overrun P90 | artwork |
|
||||
|---|---|---|---|
|
||||
| one shared `VectorPainter` per icon | −8.2% | −14.2% | unchanged |
|
||||
| MaterialSymbols glyph substitutes | −10.4% | −16.0% | **changes** |
|
||||
| **this font** | **−10.7%** | **−17.4%** | unchanged |
|
||||
| ceiling: draw no icons at all | −12.7% | −22.9% | n/a |
|
||||
|
||||
## Usage
|
||||
|
||||
pip install fonttools
|
||||
python3 tools/icon-font/build_icon_font.py <icons-dir> <out.ttf> <out.kt>
|
||||
|
||||
See the "Amethyst's own icons are also a font" section of `.claude/CLAUDE.md` for the
|
||||
mandatory regeneration step and why both outputs must be committed together.
|
||||
|
||||
## How it works
|
||||
|
||||
The `ImageVector` builder DSL maps 1:1 onto SVG path commands (`moveTo` → `M`,
|
||||
`curveToRelative` → `c`, …; none of the icons use `arcTo`), so the script extracts the
|
||||
path data, emits an SVG `d` string, and draws it into a TrueType glyph via fontTools —
|
||||
converting cubics to quadratics and flipping the y axis, since SVG grows downward and
|
||||
font outlines grow upward from the baseline.
|
||||
|
||||
Font metrics deliberately mirror the bundled `material_symbols_outlined.ttf`
|
||||
(unitsPerEm 960, ascent 1056, descent −96, advance 960) so the glyphs align with
|
||||
existing MaterialSymbols call sites and `Icon()` sizing. Generated outlines land within
|
||||
a few units of Google's own: our `Like` spans (78,94)–(882,851), their heart
|
||||
(80,120)–(880,854).
|
||||
|
||||
An icon whose path data the parser cannot reach is reported and skipped rather than
|
||||
silently emitted empty; it must keep using its `ImageVector`.
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a custom icon font from Amethyst's Kotlin ImageVector icons.
|
||||
|
||||
Why: drawing an ImageVector rasterises its paths into a per-instance cached layer,
|
||||
so a feed re-rasterises the same glyph once per card. A font glyph is a blit from
|
||||
the shared text atlas instead. Measured on the uniform-corpus macrobenchmark
|
||||
(SM-T220, 0.2% noise floor): swapping the three reaction icons to font glyphs gave
|
||||
frame P90 -10.4% vs -8.2% for per-icon shared painters, against a -12.7% ceiling.
|
||||
|
||||
This keeps Amethyst's own artwork -- it converts the existing ImageVector path data
|
||||
rather than substituting Google's glyphs, so the icons look identical.
|
||||
|
||||
Font metrics deliberately mirror the bundled material_symbols_outlined.ttf
|
||||
(unitsPerEm 960, ascent 1056, descent -96, advance 960) so the glyphs align with
|
||||
existing MaterialSymbols call sites and Icon() sizing.
|
||||
|
||||
Usage: build_icon_font.py <icons-dir> <out.ttf> <out.kt>
|
||||
"""
|
||||
import re, sys, os
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
from fontTools.pens.cu2quPen import Cu2QuPen
|
||||
from fontTools.pens.transformPen import TransformPen
|
||||
from fontTools.misc.transform import Transform
|
||||
from fontTools.svgLib.path.parser import parse_path
|
||||
|
||||
UPEM, ASCENT, DESCENT, ADVANCE = 960, 1056, -96, 960
|
||||
FIRST_CODEPOINT = 0xE900
|
||||
MAX_ERR = 1.0 # cubic->quadratic tolerance, in font units
|
||||
|
||||
# ImageVector DSL -> SVG path command. No arcTo: none of the icons use one.
|
||||
CMDS = {
|
||||
"moveTo": "M", "moveToRelative": "m",
|
||||
"lineTo": "L", "lineToRelative": "l",
|
||||
"horizontalLineTo": "H", "horizontalLineToRelative": "h",
|
||||
"verticalLineTo": "V", "verticalLineToRelative": "v",
|
||||
"curveTo": "C", "curveToRelative": "c",
|
||||
"reflectiveCurveTo": "S", "reflectiveCurveToRelative": "s",
|
||||
"quadTo": "Q", "quadToRelative": "q",
|
||||
"reflectiveQuadTo": "T", "reflectiveQuadToRelative": "t",
|
||||
"close": "Z",
|
||||
}
|
||||
CALL_RE = re.compile(r"\b(" + "|".join(CMDS) + r")\(([^()]*)\)")
|
||||
NUM_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
|
||||
|
||||
|
||||
STROKE_W_RE = re.compile(r"strokeLineWidth\s*=\s*([\d.]+)f?")
|
||||
|
||||
|
||||
def is_stroked(src: str) -> bool:
|
||||
"""True if the icon paints a stroke, not just a fill.
|
||||
|
||||
A glyph outline is filled: there is no pen width in a TrueType glyph. Converting a
|
||||
stroked icon would emit only its fill and silently change the artwork -- Zap is drawn
|
||||
as a thin outline (strokeLineWidth 1.2) and came out as a solid bolt. Such icons must
|
||||
keep their ImageVector.
|
||||
"""
|
||||
if any(float(w) > 0 for w in STROKE_W_RE.findall(src)):
|
||||
return True
|
||||
return "stroke = SolidColor" in src
|
||||
|
||||
|
||||
def kotlin_to_svg_path(src: str):
|
||||
"""Extract viewport and an SVG 'd' string from one ImageVector .kt file."""
|
||||
vw = re.search(r"viewportWidth\s*=\s*([\d.]+)f?", src)
|
||||
vh = re.search(r"viewportHeight\s*=\s*([\d.]+)f?", src)
|
||||
if vw and vh:
|
||||
viewport = (float(vw.group(1)), float(vh.group(1)))
|
||||
elif "materialIcon(" in src:
|
||||
# materialIcon() sets the viewport itself; Material's convention is 24x24.
|
||||
viewport = (24.0, 24.0)
|
||||
else:
|
||||
return None, None
|
||||
|
||||
# Only look inside the vector builder, never the @Preview composable above it.
|
||||
# Only look inside the vector builder, never the @Preview composable above it, and never
|
||||
# a helper like materialOutlinedPath() declared after it.
|
||||
start = src.find(".apply {")
|
||||
if start == -1:
|
||||
start = src.find("materialIcon(")
|
||||
body = src[start:] if start != -1 else src
|
||||
end = body.find("\ninline fun ")
|
||||
if end != -1:
|
||||
body = body[:end]
|
||||
|
||||
parts = []
|
||||
for m in CALL_RE.finditer(body):
|
||||
cmd, args = m.group(1), m.group(2)
|
||||
letter = CMDS[cmd]
|
||||
if cmd == "close":
|
||||
parts.append("Z")
|
||||
continue
|
||||
nums = NUM_RE.findall(args)
|
||||
if not nums:
|
||||
continue
|
||||
parts.append(letter + " " + " ".join(nums))
|
||||
return viewport, " ".join(parts)
|
||||
|
||||
|
||||
def build_glyph(d: str, viewport):
|
||||
vw, vh = viewport
|
||||
# Uniform scale on the larger axis keeps non-square viewports undistorted.
|
||||
s = UPEM / max(vw, vh)
|
||||
pen = TTGlyphPen(None)
|
||||
# y flips: SVG grows downward, font outlines grow upward from the baseline.
|
||||
tp = TransformPen(Cu2QuPen(pen, MAX_ERR), Transform(s, 0, 0, -s, 0, vh * s))
|
||||
parse_path(d, tp)
|
||||
return pen.glyph()
|
||||
|
||||
|
||||
def main(icons_dir, out_ttf, out_kt):
|
||||
files = sorted(f for f in os.listdir(icons_dir) if f.endswith(".kt"))
|
||||
glyphs, cmap, names, skipped = {".notdef": TTGlyphPen(None).glyph()}, {}, [".notdef"], []
|
||||
cp = FIRST_CODEPOINT
|
||||
for fn in files:
|
||||
name = fn[:-3]
|
||||
src = open(os.path.join(icons_dir, fn), encoding="utf-8").read()
|
||||
if is_stroked(src):
|
||||
skipped.append((name, "draws a stroke; a glyph can only be filled"))
|
||||
continue
|
||||
viewport, d = kotlin_to_svg_path(src)
|
||||
if not d:
|
||||
skipped.append((name, "no path data"))
|
||||
continue
|
||||
try:
|
||||
glyphs[name] = build_glyph(d, viewport)
|
||||
except Exception as e: # noqa: BLE001 - report and continue, don't kill the build
|
||||
skipped.append((name, f"{type(e).__name__}: {e}"))
|
||||
continue
|
||||
cmap[cp] = name
|
||||
names.append(name)
|
||||
print(f" {name:<12} U+{cp:04X} viewport {viewport[0]:g}x{viewport[1]:g} {len(d)} chars")
|
||||
cp += 1
|
||||
|
||||
fb = FontBuilder(UPEM, isTTF=True)
|
||||
fb.setupGlyphOrder(names)
|
||||
fb.setupCharacterMap(cmap)
|
||||
fb.setupGlyf(glyphs)
|
||||
fb.setupHorizontalMetrics({n: (ADVANCE, 0) for n in names})
|
||||
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
|
||||
fb.setupNameTable({
|
||||
"familyName": "Amethyst Icons", "styleName": "Regular",
|
||||
"psName": "AmethystIcons-Regular", "version": "1.0",
|
||||
})
|
||||
fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=DESCENT,
|
||||
usWinAscent=ASCENT, usWinDescent=abs(DESCENT))
|
||||
fb.setupPost(keepGlyphNames=False)
|
||||
fb.save(out_ttf)
|
||||
|
||||
with open(out_kt, "w", encoding="utf-8") as fh:
|
||||
fh.write("// GENERATED by tools/icon-font/build_icon_font.py -- do not edit by hand.\n")
|
||||
fh.write("package com.vitorpamplona.amethyst.commons.icons.symbols\n\n")
|
||||
fh.write("/** Amethyst's own icons as font glyphs. See the build script for why. */\n")
|
||||
fh.write("object AmethystIcons {\n")
|
||||
for code, name in sorted(cmap.items()):
|
||||
fh.write(f' val {name} = MaterialSymbol("\\u{code:04X}")\n')
|
||||
fh.write("}\n")
|
||||
|
||||
print(f"\nwrote {out_ttf} ({os.path.getsize(out_ttf)} bytes), {len(cmap)} glyphs")
|
||||
for name, why in skipped:
|
||||
print(f" SKIPPED {name}: {why}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 4:
|
||||
sys.exit(__doc__)
|
||||
main(*sys.argv[1:])
|
||||
Reference in New Issue
Block a user