Merge pull request #2459 from vitorpamplona/claude/relay-icons-group-info-23CZ8

Track relay activity for Marmot group chats
This commit is contained in:
Vitor Pamplona
2026-04-20 13:03:47 -04:00
committed by GitHub
3 changed files with 163 additions and 5 deletions
@@ -566,6 +566,9 @@ class GroupEventHandler(
return
}
val chatroom = account.marmotGroupList.getOrCreateGroup(groupId)
eventNote.relays.forEach { chatroom.recordRelayActivity(it, event.createdAt) }
try {
val result = manager.processGroupEvent(event)
Log.d("MarmotDbg") {
@@ -21,15 +21,24 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
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.Box
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.ui.Alignment
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.routes.Route
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -84,6 +104,7 @@ fun MarmotGroupInfoScreen(
val groupDescription by chatroom.description.collectAsStateWithLifecycle()
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
val groupRelays by chatroom.relays.collectAsStateWithLifecycle()
val relayActivity by chatroom.relayActivity.collectAsStateWithLifecycle()
val members by chatroom.members.collectAsStateWithLifecycle()
var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
@@ -154,11 +175,11 @@ fun MarmotGroupInfoScreen(
modifier = Modifier.padding(top = 4.dp),
)
if (groupRelays.isNotEmpty()) {
Text(
text = "Relays: ${groupRelays.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
GroupRelayList(
relayUrls = groupRelays,
relayActivity = relayActivity,
accountViewModel = accountViewModel,
nav = nav,
)
}
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.NotesGatherer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.lang.ref.WeakReference
/**
@@ -51,6 +53,14 @@ class MarmotGroupChatroom(
var newestMessage: Note? = null
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
* group, OR explicitly created/owns it. Used by list UIs to split groups
@@ -134,6 +144,16 @@ class MarmotGroupChatroom(
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> {
val sorted = messages.sortedWith(DefaultFeedOrder)
val toKeep =