perf(chats): typed sealed key instead of concatenated string

The previous fix used `"ch:${id}"`, `"dm:${users.sorted().joinToString}"`
etc., which allocates a StringBuilder + char[] + new String per call —
worst case for the DM branch which also allocates a sorted List on top.

Replace with a sealed `ChatroomLazyKey` and per-type data classes that
just wrap the existing String / RoomId / ChatroomKey. Equality and
hashCode are auto-generated, so Compose still moves rows correctly on
reorder, and we drop most of the per-key allocations:

  ch:abc          -> PublicChannelLazyKey(abc)        # 1 wrapper, reused String
  dm:userA,userB  -> PrivateChatLazyKey(chatroomKey)  # 1 wrapper, reused ChatroomKey
  eph:roomId      -> EphemeralChannelLazyKey(roomId)  # 1 wrapper, reused RoomId
This commit is contained in:
Claude
2026-04-26 15:51:15 +00:00
parent 06340dbdf9
commit 46f305fe1a
@@ -48,7 +48,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderC
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
@@ -137,38 +139,63 @@ 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.
// message id, so reorders move the row instead of recreating it. Uses a
// sealed wrapper around an existing String/RoomId/ChatroomKey to avoid the
// StringBuilder + concatenation allocations of a typed-prefix string key.
private sealed interface ChatroomLazyKey
private data class MarmotChatroomLazyKey(
val groupId: HexKey,
) : ChatroomLazyKey
private data class PublicChannelLazyKey(
val channelId: HexKey,
) : ChatroomLazyKey
private data class EphemeralChannelLazyKey(
val roomId: RoomId,
) : ChatroomLazyKey
private data class PrivateChatLazyKey(
val key: ChatroomKey,
) : ChatroomLazyKey
private data class FallbackChatroomLazyKey(
val noteIdHex: HexKey,
) : ChatroomLazyKey
private fun chatroomLazyKey(
item: Note,
myPubKey: HexKey,
): String {
): ChatroomLazyKey {
item.inGatherers
?.firstNotNullOfOrNull { it as? MarmotGroupChatroom }
?.let { return "marmot:${it.nostrGroupId}" }
?.let { return MarmotChatroomLazyKey(it.nostrGroupId) }
return when (val event = item.event) {
is ChannelMessageEvent -> {
"ch:${event.channelId() ?: item.idHex}"
PublicChannelLazyKey(event.channelId() ?: item.idHex)
}
is ChannelMetadataEvent -> {
"ch:${event.channelId() ?: item.idHex}"
PublicChannelLazyKey(event.channelId() ?: item.idHex)
}
is ChannelCreateEvent -> {
"ch:${event.id}"
PublicChannelLazyKey(event.id)
}
is EphemeralChatEvent -> {
"eph:${event.roomId()?.toKey() ?: item.idHex}"
event.roomId()?.let { EphemeralChannelLazyKey(it) }
?: FallbackChatroomLazyKey(item.idHex)
}
is ChatroomKeyable -> {
"dm:${event.chatroomKey(myPubKey).users.sorted().joinToString(",")}"
PrivateChatLazyKey(event.chatroomKey(myPubKey))
}
else -> {
item.idHex
FallbackChatroomLazyKey(item.idHex)
}
}
}