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:
@@ -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.
|
||||
|
||||
+4
@@ -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.
|
||||
|
||||
+110
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+95
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user