fix: show empty Marmot groups in Messages screen

Previously ChatroomListKnownFeedFilter dropped any group whose
newestMessage was null, so groups the user just created and groups
received via Welcome events without any activity yet never appeared
in the Messages list.

Emit a stable placeholder Note (carrying the chatroom as a gatherer)
per empty group, route it through the existing Marmot row path, and
invalidate dmKnown on MarmotGroupList.groupListChanges so newly added
or promoted groups rebuild the feed.
This commit is contained in:
Claude
2026-04-20 17:08:59 +00:00
parent f76e4f4c49
commit aea1dc53c3
5 changed files with 53 additions and 6 deletions
@@ -56,6 +56,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AccountFeedContentStates( class AccountFeedContentStates(
val account: Account, val account: Account,
@@ -100,6 +102,19 @@ class AccountFeedContentStates(
val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache) val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache)
init {
// Marmot group list changes (new group, group marked known, group
// metadata synced) don't flow through LocalCache.newEventBundles, so
// the additive update path can't see them. Force a full feed rebuild
// whenever the list changes so empty groups appear and placeholder
// rows get replaced by real messages.
scope.launch(Dispatchers.IO) {
account.marmotGroupList.groupListChanges.collect {
dmKnown.invalidateData()
}
}
}
suspend fun init() { suspend fun init() {
notificationSummary.initializeSuspend() notificationSummary.initializeSuspend()
} }
@@ -85,7 +85,11 @@ fun ChatroomHeaderCompose(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
if (baseNote.event != null) { val isEmptyMarmotPlaceholder =
baseNote.event == null &&
baseNote.inGatherers?.any { it is MarmotGroupChatroom } == true
if (baseNote.event != null || isEmptyMarmotPlaceholder) {
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
} else { } else {
val hasEvent by observeNoteHasEvent(baseNote, accountViewModel) val hasEvent by observeNoteHasEvent(baseNote, accountViewModel)
@@ -248,21 +252,27 @@ private fun MarmotGroupRoomCompose(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val authorName by observeUserName(lastMessage.author!!, accountViewModel)
val displayName by chatroom.displayName.collectAsStateWithLifecycle() val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val unread by chatroom.unreadCount.collectAsStateWithLifecycle() val unread by chatroom.unreadCount.collectAsStateWithLifecycle()
val author = lastMessage.author
val noteEvent = lastMessage.event val noteEvent = lastMessage.event
val description = noteEvent?.content?.take(200)
val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}" val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}"
val lastContent =
if (author != null && noteEvent != null) {
val authorName by observeUserName(author, accountViewModel)
"$authorName: ${noteEvent.content.take(200)}"
} else {
stringRes(R.string.marmot_group_no_messages_yet)
}
ChannelName( ChannelName(
channelIdHex = chatroom.nostrGroupId, channelIdHex = chatroom.nostrGroupId,
channelPicture = null, channelPicture = null,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, R.string.marmot_group, modifier) }, channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, R.string.marmot_group, modifier) },
channelLastTime = lastMessage.createdAt(), channelLastTime = lastMessage.createdAt(),
channelLastContent = "$authorName: $description", channelLastContent = lastContent,
hasNewMessages = unread > 0, hasNewMessages = unread > 0,
loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
@@ -79,7 +79,7 @@ class ChatroomListKnownFeedFilter(
val marmotGroups = val marmotGroups =
account.marmotGroupList.rooms.mapNotNull { _, chatroom -> account.marmotGroupList.rooms.mapNotNull { _, chatroom ->
chatroom.newestMessage chatroom.newestMessage ?: chatroom.placeholderNote()
} }
return (privateMessages + publicChannels + ephemeralChats + marmotGroups).sortedWith(DefaultFeedOrder) return (privateMessages + publicChannels + ephemeralChats + marmotGroups).sortedWith(DefaultFeedOrder)
+1
View File
@@ -284,6 +284,7 @@
<string name="public_chat">Public Chat</string> <string name="public_chat">Public Chat</string>
<string name="marmot_group">MLS Group</string> <string name="marmot_group">MLS Group</string>
<string name="marmot_group_no_messages_yet">No messages yet</string>
<string name="public_chat_title">Public Chat Metadata</string> <string name="public_chat_title">Public Chat Metadata</string>
<string name="public_chat_explainer">Public chats are visible to everyone on Nostr and anyone <string name="public_chat_explainer">Public chats are visible to everyone on Nostr and anyone
can participate on them. They are great for open communities around specific topics. can participate on them. They are great for open communities around specific topics.
@@ -59,6 +59,27 @@ class MarmotGroupChatroom(
*/ */
var ownerSentMessage: Boolean = false var ownerSentMessage: Boolean = false
// Synthetic note used by list views to represent the group when no
// messages have been received yet. Lazily created and kept stable so
// equality-based feed diffing treats it as the same row across refreshes.
private var cachedPlaceholder: Note? = null
@Synchronized
fun placeholderNote(): Note {
val existing = cachedPlaceholder
if (existing != null) return existing
val created =
Note(placeholderIdHex(nostrGroupId)).apply {
addGatherer(this@MarmotGroupChatroom)
}
cachedPlaceholder = created
return created
}
companion object {
fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId"
}
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null) private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
fun changesFlow(): MutableSharedFlow<ListChange<Note>> { fun changesFlow(): MutableSharedFlow<ListChange<Note>> {