fix(chats): stable per-chatroom LazyColumn key for messages list

The chatroom list keyed each row by `if (index == 0) index else item.idHex`.
Two problems:

1. Position 0 was hardcoded to key `0`, so when a new chatroom moved to
   the top, the existing composition slot was reused with state from the
   previous chatroom — observed as "the row updated and reordered but
   still shows the old result" right at the top.
2. For other positions, the key was the latest message's `idHex`. When a
   new message arrived in any chatroom the chatroom's representative
   Note got replaced (different idHex), so Compose threw away the row
   and rebuilt it from scratch — wasted work.

Fix: derive a stable key from chatroom identity instead of message id —
nostr group id for marmot rooms, channel id for public/ephemeral
channels, sorted user set for DMs. Falls back to `item.idHex` for
unrecognized event types (drafts etc.). Reorders now move the row;
new-message updates re-use the slot.
This commit is contained in:
Claude
2026-04-26 15:44:28 +00:00
parent 6aecfe016b
commit 06340dbdf9
@@ -25,14 +25,16 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedError
@@ -45,6 +47,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@Composable @Composable
fun ChatroomListFeedView( fun ChatroomListFeedView(
@@ -103,14 +111,16 @@ private fun FeedLoaded(
) { ) {
val items by loaded.feed.collectAsStateWithLifecycle() val items by loaded.feed.collectAsStateWithLifecycle()
val myPubKey = accountViewModel.userProfile().pubkeyHex
LazyColumn( LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding), contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState, state = listState,
) { ) {
itemsIndexed( items(
items.list, items.list,
key = { index, item -> if (index == 0) index else item.idHex }, key = { item -> chatroomLazyKey(item, myPubKey) },
) { _, item -> ) { item ->
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
ChatroomHeaderCompose( ChatroomHeaderCompose(
item, item,
@@ -125,3 +135,40 @@ private fun FeedLoaded(
} }
} }
} }
// Stable per-chatroom key — derived from chatroom identity, not the latest
// message id, so reorders move the row instead of recreating it.
private fun chatroomLazyKey(
item: Note,
myPubKey: HexKey,
): String {
item.inGatherers
?.firstNotNullOfOrNull { it as? MarmotGroupChatroom }
?.let { return "marmot:${it.nostrGroupId}" }
return when (val event = item.event) {
is ChannelMessageEvent -> {
"ch:${event.channelId() ?: item.idHex}"
}
is ChannelMetadataEvent -> {
"ch:${event.channelId() ?: item.idHex}"
}
is ChannelCreateEvent -> {
"ch:${event.id}"
}
is EphemeralChatEvent -> {
"eph:${event.roomId()?.toKey() ?: item.idHex}"
}
is ChatroomKeyable -> {
"dm:${event.chatroomKey(myPubKey).users.sorted().joinToString(",")}"
}
else -> {
item.idHex
}
}
}