Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ internal val ComposerEmojiPickerFallbackHeight = 320.dp

internal val ComposerEmojiPickerSearchExtraHeight = 112.dp

private data class EmojiSearchSnapshot(
val query: String,
val results: List<EmojiEntry>,
)

internal fun composerEmojiPaneTargetHeight(
currentImeHeight: Dp,
targetImeHeight: Dp,
Expand Down Expand Up @@ -347,10 +352,18 @@ private fun EmojiPickerContent(
val browseEmoji by produceState(initialValue = emptyList<EmojiEntry>(), 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
Loading