Merge pull request #4189 from vitorpamplona/claude/sharp-keller-7itny0

fix: address the audit of the Render/Display split — desktop uses the shared dialogs, no blank group messages
This commit is contained in:
Vitor Pamplona
2026-09-23 18:42:05 -04:00
committed by GitHub
16 changed files with 90 additions and 183 deletions
@@ -30,7 +30,7 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.ui.components.DeferredCrossfade
import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -57,8 +57,10 @@ class DeferredAnimationTest {
rule.mainClock.autoAdvance = false
rule.setContent {
DeferredCrossfade(
// enabled = true is the deferred path (DeferredCrossfade, internal to commonsUI).
CrossfadeIfEnabled(
targetState = state.value,
enabled = true,
modifier = Modifier,
contentAlignment = Alignment.TopStart,
animationSpec = tween(DURATION_MS),
@@ -41,8 +41,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip34Git.ui.CompactButtonHeight
import com.vitorpamplona.amethyst.commons.nip34Git.ui.CompactButtonPadding
import com.vitorpamplona.amethyst.commons.nip34Git.ui.GitCardButtonHeight
import com.vitorpamplona.amethyst.commons.nip34Git.ui.GitCardButtonPadding
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.git_pr_changes_retry
import com.vitorpamplona.amethyst.commons.resources.git_pr_loading_changes
@@ -108,8 +108,8 @@ fun GitPullRequestChanges(
ChangesState.Idle ->
FilledTonalButton(
onClick = { load() },
modifier = Modifier.padding(top = 8.dp).then(CompactButtonHeight),
contentPadding = CompactButtonPadding,
modifier = Modifier.padding(top = 8.dp).then(GitCardButtonHeight),
contentPadding = GitCardButtonPadding,
) {
Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_pr_view_changes), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
@@ -142,8 +142,8 @@ fun GitPullRequestChanges(
ChangesState.Failed ->
FilledTonalButton(
onClick = { load() },
modifier = Modifier.padding(top = 8.dp).then(CompactButtonHeight),
contentPadding = CompactButtonPadding,
modifier = Modifier.padding(top = 8.dp).then(GitCardButtonHeight),
contentPadding = GitCardButtonPadding,
) {
Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_pr_changes_retry), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.nip17Dm.ui.ChatroomSubjectDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -36,10 +37,12 @@ fun NewChatroomSubjectDialog(
) {
ChatroomSubjectDialog(
initialSubject =
accountViewModel.account.chatroomList.rooms
.get(room)
?.subject
?.value ?: "",
remember(room) {
accountViewModel.account.chatroomList.rooms
.get(room)
?.subject
?.value ?: ""
},
onPost = { subject, message ->
accountViewModel.launchSigner {
val template =
@@ -118,14 +118,13 @@ fun ShortEphemeralChatActionOptions(
accountViewModel: AccountViewModel,
nav: INav,
) {
JoinEphemeralChatButtonIfNotAlreadyJoined(channel, accountViewModel, nav)
JoinEphemeralChatButtonIfNotAlreadyJoined(channel, accountViewModel)
}
@Composable
fun JoinEphemeralChatButtonIfNotAlreadyJoined(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel)
@@ -198,7 +198,7 @@ fun LongChannelActionOptions(
EditButtonIfIamCreator(channel, accountViewModel, nav)
LeaveButtonIfFollowing(channel, accountViewModel, nav)
LeaveButtonIfFollowing(channel, accountViewModel)
}
@Composable
@@ -221,7 +221,6 @@ fun EditButtonIfIamCreator(
fun LeaveButtonIfFollowing(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel)
@@ -135,14 +135,13 @@ fun ShortChannelActionOptions(
}
}
JoinChatButtonIfNotAlreadyJoined(channel, accountViewModel, nav)
JoinChatButtonIfNotAlreadyJoined(channel, accountViewModel)
}
@Composable
fun JoinChatButtonIfNotAlreadyJoined(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel)
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
/**
* Rename a private group chat (NIP-17): edit the subject and write the message that carries the
* change. [onPost] receives the subject (possibly blank) and the message, then the dialog closes.
* Posting is disabled while both are blank.
*/
@Composable
fun ChatroomSubjectDialog(
@@ -94,7 +95,8 @@ fun ChatroomSubjectDialog(
onPost(groupName.value, message.value)
onClose()
},
true,
// Nothing to send without a new subject or a message.
isActive = groupName.value.isNotBlank() || message.value.isNotBlank(),
)
}
@@ -43,8 +43,8 @@ import com.vitorpamplona.amethyst.commons.resources.git_status_reopen
import com.vitorpamplona.amethyst.commons.ui.stringRes
/** Compact sizing shared by the small action buttons on git cards. */
val CompactButtonHeight = Modifier.height(32.dp)
val CompactButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)
val GitCardButtonHeight = Modifier.height(32.dp)
val GitCardButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)
/**
* NIP-34 status buttons for an issue, patch or pull request: "Reopen" when it is
@@ -67,8 +67,8 @@ fun GitStatusButtons(
if (closedOrApplied) {
FilledTonalButton(
onClick = onReopen,
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
modifier = GitCardButtonHeight,
contentPadding = GitCardButtonPadding,
) {
Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_reopen), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
@@ -77,8 +77,8 @@ fun GitStatusButtons(
if (isPatchOrPr) {
FilledTonalButton(
onClick = onMarkMerged,
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
modifier = GitCardButtonHeight,
contentPadding = GitCardButtonPadding,
) {
Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_mark_merged), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
@@ -87,8 +87,8 @@ fun GitStatusButtons(
OutlinedButton(
onClick = onClose,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
modifier = GitCardButtonHeight,
contentPadding = GitCardButtonPadding,
) {
Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_close), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
@@ -124,7 +124,7 @@ fun RelayLeaveRequestCard() {
}
@Composable
fun RelayMemberEventCard(
private fun RelayMemberEventCard(
icon: MaterialSymbol,
title: String,
subtitle: String?,
@@ -84,6 +84,18 @@ import com.vitorpamplona.amethyst.commons.ui.theme.LightRedColor
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.collections.immutable.toImmutableList
/** The NIP-56 reasons the dialog offers, in display order, with their labels. */
private val ReportReasons =
listOf(
ReportType.SPAM to Res.string.report_dialog_spam,
ReportType.PROFANITY to Res.string.report_dialog_profanity,
ReportType.IMPERSONATION to Res.string.report_dialog_impersonation,
ReportType.NUDITY to Res.string.report_dialog_nudity,
ReportType.ILLEGAL to Res.string.report_dialog_illegal,
ReportType.MALWARE to Res.string.report_malware,
ReportType.VIOLENCE to Res.string.violence,
)
/**
* Block-or-report dialog for a post. [onBlock] hides the author; [onReport] files a public
* NIP-56 report with the chosen reason and optional text (the host also hides the author).
@@ -96,18 +108,10 @@ fun ReportDialog(
onReport: (reason: ReportType, additionalReason: String) -> Unit,
onDismiss: () -> Unit,
) {
val reportTypes =
listOf(
Pair(ReportType.SPAM, stringRes(Res.string.report_dialog_spam)),
Pair(ReportType.PROFANITY, stringRes(Res.string.report_dialog_profanity)),
Pair(ReportType.IMPERSONATION, stringRes(Res.string.report_dialog_impersonation)),
Pair(ReportType.NUDITY, stringRes(Res.string.report_dialog_nudity)),
Pair(ReportType.ILLEGAL, stringRes(Res.string.report_dialog_illegal)),
Pair(ReportType.MALWARE, stringRes(Res.string.report_malware)),
Pair(ReportType.VIOLENCE, stringRes(Res.string.violence)),
)
val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() }
val labels = ReportReasons.map { stringRes(it.second) }
// Keyed on the labels so a locale change while the dialog is open relabels the spinner;
// typing in the reason field recomposes with equal labels and reuses the list.
val reasonOptions = remember(labels) { labels.map { TitleExplainer(it) }.toImmutableList() }
var additionalReason by remember { mutableStateOf("") }
var selectedReason by remember { mutableIntStateOf(-1) }
@@ -178,9 +182,9 @@ fun ReportDialog(
ActionButton(
text = stringRes(Res.string.report_dialog_post_report_btn),
icon = MaterialSymbols.Report,
enabled = selectedReason in 0..reportTypes.lastIndex,
enabled = selectedReason in 0..ReportReasons.lastIndex,
onClick = {
onReport(reportTypes[selectedReason].first, additionalReason)
onReport(ReportReasons[selectedReason].first, additionalReason)
onDismiss()
},
)
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -142,9 +141,8 @@ fun NipCardPreview() {
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoPaddingSuggestionChip(
private fun NoPaddingSuggestionChip(
label: String,
modifier: Modifier = Modifier,
) {
@@ -86,7 +86,7 @@ private class ChangeLatch {
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun <T> DeferredCrossfade(
internal fun <T> DeferredCrossfade(
targetState: T,
modifier: Modifier,
contentAlignment: Alignment,
@@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
import com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.DesktopRichText
import com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog
import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog
@@ -1685,19 +1686,19 @@ fun UserProfileScreen(
}
if (showProfileReportDialog && iAccount != null) {
com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog(
ReportNoteDialog(
onDismiss = { showProfileReportDialog = false },
onReport = { type, comment ->
onBlock = {
scope.launch {
try {
iAccount.report(pubKeyHex, type, comment)
profileSnackbar?.showSnackbar("Report sent")
iAccount.hideUser(pubKeyHex)
profileSnackbar?.showSnackbar("User muted")
} catch (e: Exception) {
profileSnackbar?.showSnackbar("Report failed: ${e.message}")
profileSnackbar?.showSnackbar("Mute failed: ${e.message}")
}
}
},
onBlockAndReport = { type, comment ->
onReport = { type, comment ->
scope.launch {
try {
iAccount.report(pubKeyHex, type, comment)
@@ -45,12 +45,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
@@ -100,6 +98,7 @@ import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.nip17Dm.ui.ChatroomSubjectDialog
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
@@ -1435,8 +1434,8 @@ private suspend fun sendEncryptedFiles(
}
/**
* Dialog to set or change a group's NIP-14 subject (name). Mirrors Android's
* NewChatroomSubjectDialog: it sends a normal NIP-17 message carrying a
* Dialog to set or change a group's NIP-14 subject (name), using the shared
* [ChatroomSubjectDialog]: it sends a normal NIP-17 message carrying a
* `subject` tag (plus an optional accompanying message) to every room member,
* so all participants pick up the new name.
*/
@@ -1449,56 +1448,23 @@ private fun GroupSubjectDialog(
onClose: () -> Unit,
) {
val scope = rememberCoroutineScope()
var groupName by remember { mutableStateOf(currentSubject) }
var message by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onClose,
title = { Text("Group name") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = groupName,
onValueChange = { groupName = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Subject") },
placeholder = { Text("A name for this group") },
singleLine = true,
)
OutlinedTextField(
value = message,
onValueChange = { message = it },
modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp),
label = { Text("Message (optional)") },
)
}
},
confirmButton = {
TextButton(
enabled = groupName.isNotBlank(),
onClick = {
scope.launch {
try {
val pTags = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it)?.toPTag() }
val template =
ChatMessageEvent.build(message, pTags) {
groupName.ifBlank { null }?.let { changeSubject(it) }
}
account.sendNip17PrivateMessage(template)
} catch (e: Exception) {
println("Failed to set group subject: ${e.message}")
ChatroomSubjectDialog(
initialSubject = currentSubject,
onPost = { subject, message ->
scope.launch {
try {
val pTags = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it)?.toPTag() }
val template =
ChatMessageEvent.build(message, pTags) {
subject.ifBlank { null }?.let { changeSubject(it) }
}
}
onClose()
},
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onClose) {
Text("Cancel")
account.sendNip17PrivateMessage(template)
} catch (e: Exception) {
println("Failed to set group subject: ${e.message}")
}
}
},
onClose = onClose,
)
}
@@ -20,90 +20,24 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.nip56Reports.ui.ReportDialog
import com.vitorpamplona.quartz.nip56Reports.ReportType
private val REPORT_OPTIONS =
listOf(
ReportType.SPAM to "Spam",
ReportType.PROFANITY to "Profanity / Hateful speech",
ReportType.IMPERSONATION to "Impersonation",
ReportType.NUDITY to "Nudity / Sexual content",
ReportType.ILLEGAL to "Illegal content",
ReportType.MALWARE to "Malware / Phishing",
)
/**
* NIP-56 report dialog. Lets the user pick a report reason and optionally add a
* comment, then either just report or report-and-block the author. Mirrors the
* Android `ReportNoteDialog`, adapted to Compose Desktop.
* NIP-56 block-or-report dialog for desktop: the shared [ReportDialog]. [onBlock] hides the
* author; [onReport] files a public report with the chosen reason and text (callers also hide
* the author, as Android does).
*/
@Composable
fun ReportNoteDialog(
onDismiss: () -> Unit,
onBlock: () -> Unit,
onReport: (ReportType, String) -> Unit,
onBlockAndReport: (ReportType, String) -> Unit,
) {
var selected by remember { mutableStateOf(REPORT_OPTIONS.first().first) }
var comment by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Report note") },
text = {
Column {
REPORT_OPTIONS.forEach { (type, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.selectable(selected = selected == type, onClick = { selected = type })
.padding(vertical = 2.dp),
) {
RadioButton(selected = selected == type, onClick = { selected = type })
Text(label, modifier = Modifier.padding(start = 4.dp))
}
}
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
label = { Text("Comment (optional)") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(onClick = {
onReport(selected, comment)
onDismiss()
}) { Text("Report") }
},
dismissButton = {
Row {
TextButton(onClick = {
onBlockAndReport(selected, comment)
onDismiss()
}) { Text("Block & report") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
ReportDialog(
onBlock = onBlock,
onReport = onReport,
onDismiss = onDismiss,
)
}
@@ -159,17 +159,17 @@ fun NoteReportDialog(
val scope = rememberCoroutineScope()
ReportNoteDialog(
onDismiss = onDismiss,
onReport = { type, comment ->
onBlock = {
scope.launch {
try {
account.reportEvent(event, type, comment)
snackbar?.showSnackbar("Report sent")
account.hideUser(event.pubKey)
snackbar?.showSnackbar("User muted")
} catch (e: Exception) {
snackbar?.showSnackbar("Report failed: ${e.message}")
snackbar?.showSnackbar("Mute failed: ${e.message}")
}
}
},
onBlockAndReport = { type, comment ->
onReport = { type, comment ->
scope.launch {
try {
account.reportEvent(event, type, comment)