feat: show Marmot group relays as clickable icons with activity status

Replaces the comma-separated relay text on the Marmot Group Info
screen with a FlowRow of 55dp relay avatars that open RelayInfo on
tap and copy the URL on long-press, matching the relay-icon pattern
used elsewhere in the app.

To surface whether each configured relay is actually carrying traffic
for the group, MarmotGroupChatroom now tracks the most recent
kind:445 event timestamp observed from each delivering relay and the
info screen renders a green dot when a relay has delivered a group
event within the last 7 days (gray otherwise).
This commit is contained in:
Claude
2026-04-20 16:59:19 +00:00
parent f76e4f4c49
commit 42e226bf8d
3 changed files with 163 additions and 5 deletions
@@ -566,6 +566,9 @@ class GroupEventHandler(
return return
} }
val chatroom = account.marmotGroupList.getOrCreateGroup(groupId)
eventNote.relays.forEach { chatroom.recordRelayActivity(it, event.createdAt) }
try { try {
val result = manager.processGroupEvent(event) val result = manager.processGroupEvent(event)
Log.d("MarmotDbg") { Log.d("MarmotDbg") {
@@ -21,15 +21,24 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.automirrored.filled.ExitToApp
@@ -54,18 +63,29 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -84,6 +104,7 @@ fun MarmotGroupInfoScreen(
val groupDescription by chatroom.description.collectAsStateWithLifecycle() val groupDescription by chatroom.description.collectAsStateWithLifecycle()
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
val groupRelays by chatroom.relays.collectAsStateWithLifecycle() val groupRelays by chatroom.relays.collectAsStateWithLifecycle()
val relayActivity by chatroom.relayActivity.collectAsStateWithLifecycle()
val members by chatroom.members.collectAsStateWithLifecycle() val members by chatroom.members.collectAsStateWithLifecycle()
var showLeaveDialog by remember { mutableStateOf(false) } var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) }
@@ -154,11 +175,11 @@ fun MarmotGroupInfoScreen(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
if (groupRelays.isNotEmpty()) { if (groupRelays.isNotEmpty()) {
Text( GroupRelayList(
text = "Relays: ${groupRelays.joinToString(", ")}", relayUrls = groupRelays,
style = MaterialTheme.typography.bodySmall, relayActivity = relayActivity,
color = MaterialTheme.colorScheme.onSurfaceVariant, accountViewModel = accountViewModel,
modifier = Modifier.padding(top = 2.dp), nav = nav,
) )
} }
val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId) val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId)
@@ -338,3 +359,117 @@ fun LeaveGroupDialog(
}, },
) )
} }
/** Window (in seconds) within which a relay is considered actively carrying this group's traffic. */
private const val RELAY_ACTIVITY_WINDOW_SECS = 7L * 24 * 60 * 60
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun GroupRelayList(
relayUrls: List<String>,
relayActivity: Map<NormalizedRelayUrl, Long>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val normalized =
remember(relayUrls) {
relayUrls.mapNotNull { url -> url.normalizeRelayUrlOrNull()?.let { url to it } }
}
if (normalized.isEmpty()) return
Column(modifier = Modifier.padding(top = 8.dp)) {
Text(
text = "Relays",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FlowRow(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val nowSeconds = System.currentTimeMillis() / 1000L
normalized.forEach { (raw, relay) ->
val lastSeen = relayActivity[relay]
val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS
GroupRelayTile(
relay = relay,
fallbackUrl = raw,
isActive = isActive,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GroupRelayTile(
relay: NormalizedRelayUrl,
fallbackUrl: String,
isActive: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relayInfo by loadRelayInfo(relay)
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
val clickableModifier =
remember(relay) {
Modifier
.size(55.dp)
.combinedClickable(
indication = ripple24dp,
interactionSource = MutableInteractionSource(),
onLongClick = {
scope.launch {
clipboardManager.setText(relay.url)
}
},
onClick = { nav.nav(Route.RelayInfo(relay.url)) },
)
}
Box(
modifier = clickableModifier,
contentAlignment = Alignment.Center,
) {
RenderRelayIcon(
displayUrl = relayInfo.id ?: fallbackUrl,
iconUrl = relayInfo.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
pingInMs = 0,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
iconModifier = LargeRelayIconModifier,
)
val dotColor =
if (isActive) {
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colorScheme.placeholderText
}
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.padding(2.dp),
) {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(dotColor),
)
}
}
}
@@ -27,9 +27,11 @@ import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NotesGatherer import com.vitorpamplona.amethyst.commons.model.NotesGatherer
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
/** /**
@@ -51,6 +53,14 @@ class MarmotGroupChatroom(
var newestMessage: Note? = null var newestMessage: Note? = null
val unreadCount = MutableStateFlow(0) val unreadCount = MutableStateFlow(0)
/**
* Tracks the most recent createdAt (seconds) of a kind:445 group event
* observed from each relay, keyed by the relay that delivered it. Used by
* the Group Info screen to flag which configured relays are actively
* carrying traffic for this MLS group.
*/
val relayActivity = MutableStateFlow<Map<NormalizedRelayUrl, Long>>(emptyMap())
/** /**
* True if the local user has ever sent an application message in this * True if the local user has ever sent an application message in this
* group, OR explicitly created/owns it. Used by list UIs to split groups * group, OR explicitly created/owns it. Used by list UIs to split groups
@@ -134,6 +144,16 @@ class MarmotGroupChatroom(
unreadCount.value = 0 unreadCount.value = 0
} }
fun recordRelayActivity(
relay: NormalizedRelayUrl,
createdAt: Long,
) {
relayActivity.update { current ->
val existing = current[relay] ?: 0L
if (createdAt > existing) current + (relay to createdAt) else current
}
}
fun pruneMessagesToTheLatestOnly(): Set<Note> { fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = messages.sortedWith(DefaultFeedOrder) val sorted = messages.sortedWith(DefaultFeedOrder)
val toKeep = val toKeep =