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(), resolvedEmojis: Map<String, String> = emptyMap(),
unicodeEmojis: List<String> = emptyList(), unicodeEmojis: List<String> = emptyList(),
onOpenEmojiLibrary: (() -> Unit)? = null, onOpenEmojiLibrary: (() -> Unit)? = null,
isPrivate: Boolean = false,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
@@ -107,6 +108,9 @@ fun ActionBar(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1 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)) Spacer(Modifier.width(8.dp))
Box { Box {
Box( Box(
@@ -243,6 +247,7 @@ fun ActionBar(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
} // end !isPrivate
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
IconButton(onClick = onAddToList) { IconButton(onClick = onAddToList) {
Icon( Icon(
@@ -806,6 +806,7 @@ fun PostCard(
resolvedEmojis = resolvedEmojis, resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis, unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary, onOpenEmojiLibrary = onOpenEmojiLibrary,
isPrivate = isPrivate,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Icon( Icon(
@@ -182,6 +182,11 @@ fun ComposeScreen(
val scheduleEnabled by viewModel.scheduleEnabled.collectAsState() val scheduleEnabled by viewModel.scheduleEnabled.collectAsState()
val scheduleTimestamp by viewModel.scheduleTimestamp.collectAsState() val scheduleTimestamp by viewModel.scheduleTimestamp.collectAsState()
val privateReply by viewModel.privateReply.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 powStatus = powManager?.status?.collectAsState()?.value ?: PowStatus.Idle
val isMiningBusy = powStatus is PowStatus.Mining val isMiningBusy = powStatus is PowStatus.Mining
val context = LocalContext.current val context = LocalContext.current
@@ -123,10 +123,24 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
private val _privateReply = MutableStateFlow(false) private val _privateReply = MutableStateFlow(false)
val privateReply: StateFlow<Boolean> = _privateReply 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() { fun togglePrivateReply() {
if (_privateReplyLocked.value) return
_privateReply.value = !_privateReply.value _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) private val _powEnabled = MutableStateFlow(false)
val powEnabled: StateFlow<Boolean> = _powEnabled val powEnabled: StateFlow<Boolean> = _powEnabled
@@ -1203,6 +1217,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
_hashtags.value = emptyList() _hashtags.value = emptyList()
_powEnabled.value = false _powEnabled.value = false
_privateReply.value = false _privateReply.value = false
_privateReplyLocked.value = false
_galleryMode.value = false _galleryMode.value = false
_galleryHasVideo.value = false _galleryHasVideo.value = false
_uploadedMediaMeta.clear() _uploadedMediaMeta.clear()