feat(private-replies): hide noisy actions and chain privacy

- ActionBar gates React / Repost / Quote / Zap behind !isPrivate, leaving
  Reply and Bookmark on private replies. Avoids leaking the rumor id via
  a public e-tag on kind 7 / 6 / 9735 attached to the rumor.
- ComposeViewModel auto-enables and locks the private toggle when the
  parent being replied to is itself a private reply. Sending publicly
  would attach an e-tag to the rumor id on public relays and leak the
  thread structure, so the toggle is forced on and ignores clicks.
This commit is contained in:
Barry Deen
2026-05-16 18:36:52 -04:00
parent ecf5b367b4
commit 7018be95dc
4 changed files with 26 additions and 0 deletions
@@ -84,6 +84,7 @@ fun ActionBar(
resolvedEmojis: Map<String, String> = emptyMap(),
unicodeEmojis: List<String> = emptyList(),
onOpenEmojiLibrary: (() -> Unit)? = null,
isPrivate: Boolean = false,
modifier: Modifier = Modifier
) {
val context = androidx.compose.ui.platform.LocalContext.current
@@ -107,6 +108,9 @@ fun ActionBar(
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
// Private replies hide React / Repost / Quote / Zap to avoid leaking the rumor id
// on public relays. Reply (above) and Bookmark (below) remain available.
if (!isPrivate) {
Spacer(Modifier.width(8.dp))
Box {
Box(
@@ -243,6 +247,7 @@ fun ActionBar(
overflow = TextOverflow.Ellipsis
)
}
} // end !isPrivate
Spacer(Modifier.width(8.dp))
IconButton(onClick = onAddToList) {
Icon(
@@ -806,6 +806,7 @@ fun PostCard(
resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
isPrivate = isPrivate,
modifier = Modifier.weight(1f)
)
Icon(
@@ -182,6 +182,11 @@ fun ComposeScreen(
val scheduleEnabled by viewModel.scheduleEnabled.collectAsState()
val scheduleTimestamp by viewModel.scheduleTimestamp.collectAsState()
val privateReply by viewModel.privateReply.collectAsState()
val privateReplyLocked by viewModel.privateReplyLocked.collectAsState()
LaunchedEffect(replyTo) {
viewModel.configureForReply(replyTo)
}
val powStatus = powManager?.status?.collectAsState()?.value ?: PowStatus.Idle
val isMiningBusy = powStatus is PowStatus.Mining
val context = LocalContext.current
@@ -123,10 +123,24 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
private val _privateReply = MutableStateFlow(false)
val privateReply: StateFlow<Boolean> = _privateReply
// Locked = the user is replying to a private reply, so the new reply must also be private
// (sending publicly would attach an e-tag to the rumor id on public relays, leaking metadata).
private val _privateReplyLocked = MutableStateFlow(false)
val privateReplyLocked: StateFlow<Boolean> = _privateReplyLocked
fun togglePrivateReply() {
if (_privateReplyLocked.value) return
_privateReply.value = !_privateReply.value
}
/** Called by ComposeScreen when the screen mounts with [replyTo]; auto-enables
* + locks the private toggle if [replyTo] is itself a private reply we received. */
fun configureForReply(replyTo: NostrEvent?) {
val isReplyingToPrivate = replyTo != null && eventRepo?.isPrivateReply(replyTo.id) == true
_privateReplyLocked.value = isReplyingToPrivate
if (isReplyingToPrivate) _privateReply.value = true
}
private val _powEnabled = MutableStateFlow(false)
val powEnabled: StateFlow<Boolean> = _powEnabled
@@ -1203,6 +1217,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
_hashtags.value = emptyList()
_powEnabled.value = false
_privateReply.value = false
_privateReplyLocked.value = false
_galleryMode.value = false
_galleryHasVideo.value = false
_uploadedMediaMeta.clear()