Merge pull request #2536 from vitorpamplona/claude/review-marmot-implementation-BSrzC

Add leave/rejoin, reactions, deletions, and Marmot reset functionality
This commit is contained in:
Vitor Pamplona
2026-04-23 19:00:24 -04:00
committed by GitHub
16 changed files with 1169 additions and 38 deletions
@@ -2334,6 +2334,29 @@ class Account(
client.publish(outbound.signedEvent, groupRelays)
}
/**
* User-initiated "nuclear" reset for the Marmot subsystem.
*
* Wipes every MLS group, every retained epoch secret, every persisted
* KeyPackage bundle, every relay subscription and every in-memory
* chatroom associated with this account. Does NOT broadcast any
* SelfRemove/leave commits to peers — if the user is in this flow at
* all, local state may already be unusable and a graceful leave is
* probably not possible. Peers will see the user as unresponsive until
* their next commit evicts the stale leaf.
*
* A fresh KeyPackage will be republished lazily on the next
* `ensureMarmotKeyPackagePublished` cycle, so the account remains
* reachable for future group invites.
*/
suspend fun resetMarmotState() {
Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${signer.pubKey.take(8)}" }
marmotManager?.resetAllState()
for (groupId in marmotGroupList.allGroupIds()) {
marmotGroupList.removeGroup(groupId)
}
}
/**
* Remove a member from a Marmot MLS group.
* Publishes the commit GroupEvent to group relays.
@@ -1536,6 +1536,10 @@ class AccountViewModel(
account.leaveMarmotGroup(nostrGroupId, relays)
}
suspend fun resetMarmotState() {
account.resetMarmotState()
}
/**
* Get the relay set for a Marmot group from MLS GroupContext metadata.
* Falls back to outbox relays if the group has no configured relays.
@@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier
@@ -213,6 +214,17 @@ fun MarmotGroupInfoScreen(
// Members list (scrollable, takes remaining vertical space)
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
if (groupRelays.isNotEmpty()) {
item {
RelayHealthSection(
relayUrls = groupRelays,
relayActivity = relayActivity,
nav = nav,
)
HorizontalDivider()
}
}
item {
Text(
text = "Members",
@@ -784,3 +796,101 @@ fun GroupRelayTile(
}
}
}
/**
* Detailed per-relay listing showing URL, connection-status dot, and the
* freshness of the most recent kind:445 group event we've received from
* each relay. Complements the compact [GroupRelayStrip] at the top of the
* screen that one is an at-a-glance visual; this one surfaces the
* underlying data so users can diagnose which relay is lagging.
*/
@Composable
fun RelayHealthSection(
relayUrls: List<String>,
relayActivity: Map<NormalizedRelayUrl, Long>,
nav: INav,
) {
val normalized =
remember(relayUrls) {
relayUrls.mapNotNull { url -> url.normalizeRelayUrlOrNull()?.let { url to it } }
}
if (normalized.isEmpty()) return
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Relays",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
val nowSeconds = System.currentTimeMillis() / 1000L
normalized.forEach { (raw, relay) ->
val lastSeen = relayActivity[relay]
val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS
RelayHealthRow(
relay = relay,
fallbackUrl = raw,
lastSeen = lastSeen,
isActive = isActive,
nav = nav,
)
}
}
}
@Composable
private fun RelayHealthRow(
relay: NormalizedRelayUrl,
fallbackUrl: String,
lastSeen: Long?,
isActive: Boolean,
nav: INav,
) {
val context = LocalContext.current
val dotColor =
if (isActive) {
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colorScheme.placeholderText
}
val subtitle =
if (lastSeen == null) {
"no events yet"
} else {
"last event${timeAgo(lastSeen, context)}"
}
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.RelayInfo(relay.url)) }
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(dotColor),
)
Column(
modifier =
Modifier
.padding(start = 12.dp)
.weight(1f),
) {
Text(
text = relay.url.ifEmpty { fallbackUrl },
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -28,16 +29,27 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -54,6 +66,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Preview
@Composable
@@ -72,6 +86,10 @@ fun AllSettingsScreen(
nav: INav,
) {
val tint = MaterialTheme.colorScheme.onBackground
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showResetMarmotDialog by remember { mutableStateOf(false) }
var isResettingMarmot by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -232,8 +250,85 @@ fun AllSettingsScreen(
tint = tint,
onClick = { nav.nav(Route.VanishEvents) },
)
HorizontalDivider()
SettingsNavigationRow(
title = R.string.reset_marmot_state,
icon = MaterialSymbols.DeleteSweep,
tint = tint,
onClick = { if (!isResettingMarmot) showResetMarmotDialog = true },
)
}
}
if (showResetMarmotDialog) {
ResetMarmotStateDialog(
onConfirm = {
showResetMarmotDialog = false
isResettingMarmot = true
scope.launch(Dispatchers.IO) {
val successMessage = stringRes(context, R.string.reset_marmot_success)
try {
accountViewModel.resetMarmotState()
launch(Dispatchers.Main) {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
val failureMessage =
stringRes(context, R.string.reset_marmot_failure, e.message ?: "")
launch(Dispatchers.Main) {
Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show()
}
} finally {
isResettingMarmot = false
}
}
},
onDismiss = { showResetMarmotDialog = false },
)
}
}
@Composable
private fun ResetMarmotStateDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp),
)
},
title = {
Text(
text = stringRes(R.string.reset_marmot_confirm_title),
textAlign = TextAlign.Center,
)
},
text = {
Text(text = stringRes(R.string.reset_marmot_confirm_body))
},
confirmButton = {
Button(
onClick = onConfirm,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringRes(R.string.reset_marmot_confirm_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
@Composable
+6
View File
@@ -1122,6 +1122,12 @@
<string name="account_settings">Account Settings</string>
<string name="app_settings">App Settings</string>
<string name="danger_zone">Danger Zone</string>
<string name="reset_marmot_state">Reset Marmot State</string>
<string name="reset_marmot_confirm_title">Reset Marmot State?</string>
<string name="reset_marmot_confirm_body">This will permanently delete every Marmot group chat, message history, and MLS key on this device for the current account. Peers will not be notified and may still see you in groups until their next commit. This cannot be undone. A new KeyPackage will be published the next time the app syncs.</string>
<string name="reset_marmot_confirm_action">Reset</string>
<string name="reset_marmot_success">Marmot state reset.</string>
<string name="reset_marmot_failure">Failed to reset Marmot state: %1$s</string>
<string name="connectivity_type_always">Always</string>
<string name="connectivity_type_wifi_only">Wifi-only</string>
<string name="connectivity_type_unmetered_wifi_only">Unmetered WiFi</string>