diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 9e24c48ed..552bd56eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -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") { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 706e10f64..ea2fe7886 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -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, + relayActivity: Map, + 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), + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index bf9ec65a0..537976e56 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -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>(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 { val sorted = messages.sortedWith(DefaultFeedOrder) val toKeep =