diff --git a/app/src/main/java/dev/ipf/whitenoise/android/ui/chats/ChatsScreen.kt b/app/src/main/java/dev/ipf/whitenoise/android/ui/chats/ChatsScreen.kt index ddfee94bc..b13377f02 100644 --- a/app/src/main/java/dev/ipf/whitenoise/android/ui/chats/ChatsScreen.kt +++ b/app/src/main/java/dev/ipf/whitenoise/android/ui/chats/ChatsScreen.kt @@ -50,7 +50,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -96,9 +95,11 @@ import dev.ipf.whitenoise.android.ui.common.rememberGroupTitleCopy import dev.ipf.whitenoise.android.ui.theme.Dimens import dev.ipf.whitenoise.android.updates.AppUpdateInfo import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -229,6 +230,7 @@ internal fun ChatsScreen( // unrelated chat-list republishes or row reordering must not restart a // full-corpus body search while the user is typing (#1201). val trimmedQuery = searchQuery.trim() + val ciSearchNeedle = remember(trimmedQuery) { trimmedQuery.lowercase(Locale.ROOT) } val bodySearchGroupIds = remember(sourceList) { sourceList.map { it.id }.sorted() } LaunchedEffect(trimmedQuery, showArchived, bodySearchGroupIds) { if (trimmedQuery.isEmpty()) { @@ -334,19 +336,20 @@ internal fun ChatsScreen( // Hysteresis for the jump-to-top button: show once the user is ≥ 5 rows // deep, hide only after they climb back to ≤ 2. The 3–4 dead band keeps a // quick scroll wiggle near the threshold from toggling the button (issue - // #413). derivedStateOf reads the previous decision so the band is sticky. + // #413). The previous decision keeps the band sticky. var jumpToTopVisible by remember(showArchived) { mutableStateOf(false) } - // Keyed on chatListState: switching active/archived recreates the list - // state under key(showArchived) above, so the derived read must re-bind to - // the new state or the FAB would keep observing the disposed one and reflect - // a stale scroll position after the toggle (issue #413 review). - val firstVisibleIndex by remember(chatListState) { derivedStateOf { chatListState.firstVisibleItemIndex } } - LaunchedEffect(firstVisibleIndex) { - if (firstVisibleIndex >= CHAT_LIST_JUMP_TO_TOP_SHOW_INDEX) { - jumpToTopVisible = true - } else if (firstVisibleIndex <= CHAT_LIST_JUMP_TO_TOP_HIDE_INDEX) { - jumpToTopVisible = false - } + // Observe scroll-index changes in an effect so the whole screen does not + // subscribe to every LazyColumn index update during a fling. + LaunchedEffect(chatListState) { + snapshotFlow { chatListState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { firstVisibleIndex -> + if (firstVisibleIndex >= CHAT_LIST_JUMP_TO_TOP_SHOW_INDEX) { + jumpToTopVisible = true + } else if (firstVisibleIndex <= CHAT_LIST_JUMP_TO_TOP_HIDE_INDEX) { + jumpToTopVisible = false + } + } } // Snap the list flush to the top when a different chat reorders into // position 0 (issue #541 / #1313). A send bumps the messaged conversation @@ -601,13 +604,22 @@ internal fun ChatsScreen( // classification can't drift from the filter. val rawBodyMatch = bodyMatches[item.id] val bodyMatch = - rawBodyMatch?.takeUnless { - ChatListMessageSearch.titleOrPreviewMatches( - displayTitle = chatListItemDisplayTitle(item, appState, groupTitleCopy), - previewText = item.projectedPreviewText(), - ciNeedle = trimmedQuery.lowercase(), - description = item.group.description, - ) + remember( + item, + appState, + groupTitleCopy, + ciSearchNeedle, + profileRev, + rawBodyMatch, + ) { + rawBodyMatch?.takeUnless { + ChatListMessageSearch.titleOrPreviewMatches( + displayTitle = chatListItemDisplayTitle(item, appState, groupTitleCopy), + previewText = item.projectedPreviewText(), + ciNeedle = ciSearchNeedle, + description = item.group.description, + ) + } } ChatListRow( item = item, diff --git a/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/composer/ComposerBar.kt b/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/composer/ComposerBar.kt index 77426ead0..b2c0813ce 100644 --- a/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/composer/ComposerBar.kt +++ b/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/composer/ComposerBar.kt @@ -763,6 +763,17 @@ internal fun ComposerBar( } else if (replyingTo != null) { val refs = remember(replyingTo.tags) { MediaReferenceParser.parseAllImetaTags(replyingTo.tags) } val mediaKind = remember(refs) { replyMediaKindFromMime(refs.firstOrNull()?.mediaType) } + val profileRevision = appState?.profileRevisionForCompose + val replyMentionDisplayName = + remember(appState, profileRevision) { + appState?.let { state -> + { bech32: String -> state.mentionDisplayName(bech32) } + } + } + val replyBody = + remember(replyingTo, messageTextCopy) { + MessageProjector.displayBody(replyingTo, messageTextCopy) + } ReplyPreviewCard( senderTitle = if (replyingTo.direction == "sent") { @@ -771,11 +782,11 @@ internal fun ComposerBar( appState?.displayName(replyingTo.sender) ?: replyingTo.sender.take(8) }, isOwn = replyingTo.direction == "sent", - body = MessageProjector.displayBody(replyingTo, messageTextCopy), + body = replyBody, mediaKind = mediaKind, onClick = null, onDismiss = onCancelReply, - mentionDisplayName = appState?.let { state -> { state.mentionDisplayName(it) } }, + mentionDisplayName = replyMentionDisplayName, ) } // #414: live @-mention picker. Compute the open query from the current diff --git a/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/composer/EmojiPicker.kt b/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/composer/EmojiPicker.kt index 25bbca3e3..2194f542e 100644 --- a/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/composer/EmojiPicker.kt +++ b/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/composer/EmojiPicker.kt @@ -218,6 +218,11 @@ internal val ComposerEmojiPickerFallbackHeight = 320.dp internal val ComposerEmojiPickerSearchExtraHeight = 112.dp +private data class EmojiSearchSnapshot( + val query: String, + val results: List, +) + internal fun composerEmojiPaneTargetHeight( currentImeHeight: Dp, targetImeHeight: Dp, @@ -347,10 +352,18 @@ private fun EmojiPickerContent( val browseEmoji by produceState(initialValue = emptyList(), context) { value = withContext(Dispatchers.IO) { EmojiData.load(context) } } - val searchResults = - remember(searchQuery, browseEmoji) { - EmojiData.search(browseEmoji, searchQuery) + val searchSnapshot by + produceState( + initialValue = EmojiSearchSnapshot(query = "", results = emptyList()), + searchQuery, + browseEmoji, + ) { + val query = searchQuery + val results = + if (query.isBlank()) emptyList() else withContext(Dispatchers.Default) { EmojiData.search(browseEmoji, query) } + value = EmojiSearchSnapshot(query = query, results = results) } + val searchResults = searchSnapshot.results.takeIf { searchSnapshot.query == searchQuery }.orEmpty() val grouped = remember(browseEmoji) { browseEmoji.groupBy { it.group } } val messageReactions = remember(messageReactionEmojis) { diff --git a/app/src/test/java/dev/ipf/whitenoise/android/ui/ComposeHotPathCoverageTest.kt b/app/src/test/java/dev/ipf/whitenoise/android/ui/ComposeHotPathCoverageTest.kt new file mode 100644 index 000000000..99b51c66d --- /dev/null +++ b/app/src/test/java/dev/ipf/whitenoise/android/ui/ComposeHotPathCoverageTest.kt @@ -0,0 +1,97 @@ +package dev.ipf.whitenoise.android.ui + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class ComposeHotPathCoverageTest { + @Test + fun composerReplyProjectionAndMentionResolverAreRemembered() { + val source = source("conversation/composer/ComposerBar.kt").readText() + val replyMarker = "} else if (replyingTo != null) {" + val replyStart = source.indexOf(replyMarker) + require(replyStart >= 0) { "Reply branch marker missing" } + val replyBodyStart = replyStart + replyMarker.length + val mentionPickerMarker = "// #414: live @-mention picker" + val mentionPickerStart = source.indexOf(mentionPickerMarker, replyBodyStart) + require(mentionPickerStart >= 0) { "Mention picker marker missing" } + val replyBlock = source.substring(replyBodyStart, mentionPickerStart) + + assertTrue( + "reply mention resolver must be stable until profile presentation changes", + Regex( + """remember\(appState,\s*profileRevision\)\s*\{.*?state\.mentionDisplayName\(bech32\).*?}""", + RegexOption.DOT_MATCHES_ALL, + ).containsMatchIn(replyBlock), + ) + assertTrue( + "reply body projection must be cached by the reply and localized message copy", + Regex( + """remember\(replyingTo,\s*messageTextCopy\)\s*\{\s*MessageProjector\.displayBody\(replyingTo,\s*messageTextCopy\)""", + ).containsMatchIn(replyBlock), + ) + assertFalse( + "ReplyPreviewCard must not receive a fresh mention resolver lambda", + "mentionDisplayName = appState?.let" in source, + ) + assertFalse( + "the composer must not subscribe to profile revisions without a reply preview", + "profileRevisionForCompose" in source.substring(0, replyStart), + ) + } + + @Test + fun emojiSearchRunsOutsideCompositionAndOffTheMainThread() { + val source = source("conversation/composer/EmojiPicker.kt").readText() + + assertTrue( + "emoji search must be produced asynchronously", + "initialValue = EmojiSearchSnapshot(query = \"\", results = emptyList())" in source, + ) + assertTrue( + "emoji filtering must run on the Default dispatcher", + "withContext(Dispatchers.Default) { EmojiData.search(browseEmoji, query) }" in source, + ) + assertFalse( + "emoji filtering must not run synchronously from remember during composition", + Regex("""remember\(searchQuery,\s*browseEmoji\)\s*\{\s*EmojiData\.search""") + .containsMatchIn(source), + ) + assertTrue( + "results from a superseded query must not remain selectable", + "searchSnapshot.results.takeIf { searchSnapshot.query == searchQuery }.orEmpty()" in source, + ) + } + + @Test + fun chatListScrollAndRowSearchWorkAreIsolatedFromScreenComposition() { + val source = source("chats/ChatsScreen.kt").readText() + + assertTrue( + "scroll index must be observed from snapshotFlow", + "snapshotFlow { chatListState.firstVisibleItemIndex }" in source, + ) + assertFalse( + "ChatsScreen must not subscribe to the scroll index through derivedStateOf", + "derivedStateOf { chatListState.firstVisibleItemIndex }" in source, + ) + assertTrue( + "the normalized search needle must be remembered once per query", + "val ciSearchNeedle = remember(trimmedQuery) { trimmedQuery.lowercase(Locale.ROOT) }" in source, + ) + assertTrue( + "per-row title and preview classification must be memoized", + Regex( + """remember\(\s*item,\s*appState,\s*groupTitleCopy,\s*ciSearchNeedle,\s*profileRev,\s*rawBodyMatch,""", + ).containsMatchIn(source), + ) + } + + private fun source(relativePath: String): File = + listOf( + File("src/main/java/dev/ipf/whitenoise/android/ui/$relativePath"), + File("app/src/main/java/dev/ipf/whitenoise/android/ui/$relativePath"), + ).firstOrNull { it.exists() } + ?: error("Missing source file: $relativePath") +}