feat(relayauth): follow-trust default + action-aware prompt copy

User-perspective fixes so the auth prompt stops feeling like "why does
this app keep asking me to log into things":

1. Default policy is now TRUSTED_FOLLOWS (new installs only; a persisted
   choice is untouched). Messaging/notifying people you follow just works;
   only strangers prompt.
2/3. The prompt is reframed around what the user was doing and its
   consequence, not "log in":
   - Title is action-aware: "Send your message to Alice?" / "Notify …?" /
     "Load posts from …?", resolving the counterparty's display name.
   - The message states the real tradeoff (the relay confirms it's you;
     its operator sees which account you are) instead of "log in".
   - A purpose-specific, error-tinted consequence line — "If you don't,
     your message to Alice won't be delivered." — so Block/Dismiss no
     longer silently break the exact thing the user was trying to do.
   Per-purpose reason labels now show only when a challenge spans multiple
   purposes (the title carries the single-purpose case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
This commit is contained in:
Claude
2026-07-10 22:40:11 +00:00
parent af0274bb24
commit eea072ec42
4 changed files with 93 additions and 10 deletions
@@ -638,7 +638,7 @@ object LocalPreferences {
val defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.IF_IN_MY_LIST
?: RelayAuthPolicy.TRUSTED_FOLLOWS
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
val relayAuthTrustFollowsForReads = getBoolean(PrefKeys.RELAY_AUTH_TRUST_FOLLOWS_FOR_READS, false)
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
@@ -273,7 +273,7 @@ class AccountSettings(
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000,
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.TRUSTED_FOLLOWS),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
val relayAuthTrustFollowsForReads: MutableStateFlow<Boolean> = MutableStateFlow(false),
) : EphemeralChatRepository,
@@ -53,10 +53,12 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPrompt
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.UserAuthChoice
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -103,6 +105,12 @@ private fun RelayAuthPromptDialog(
accountViewModel: AccountViewModel,
onChoice: (UserAuthChoice) -> Unit,
) {
// The action the user was actually doing drives the title and the "if you don't" consequence,
// so the out-of-context prompt reconnects to their intent.
val primary = remember(prompt) { prompt.purposes.primaryNamed() }
val who = primary?.let { counterpartyLabel(it.counterparties, accountViewModel) }
val showLabels = prompt.purposes.size > 1
AlertDialog(
onDismissRequest = { onChoice(UserAuthChoice.DISMISS) },
icon = {
@@ -112,7 +120,7 @@ private fun RelayAuthPromptDialog(
tint = MaterialTheme.colorScheme.primary,
)
},
title = { Text(stringRes(R.string.relay_auth_prompt_title)) },
title = { Text(titleFor(primary?.kind, who)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
@@ -123,11 +131,13 @@ private fun RelayAuthPromptDialog(
prompt.purposes.forEach { purpose ->
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = stringRes(reasonRes(purpose.kind)),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (showLabels) {
Text(
text = stringRes(reasonRes(purpose.kind)),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val people = purpose.counterparties.toList()
if (people.size <= NAMED_ROWS_MAX) {
people.forEach { CounterpartyRow(it, accountViewModel) }
@@ -136,6 +146,14 @@ private fun RelayAuthPromptDialog(
}
}
}
consequenceFor(primary?.kind, who)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
@@ -249,3 +267,59 @@ private fun reasonRes(kind: AuthPurposeKind): Int =
AuthPurposeKind.READ_OUTBOX -> R.string.relay_auth_reason_read_outbox
AuthPurposeKind.MY_OWN_RELAY -> R.string.relay_auth_reason_my_own_relay
}
/** The purpose whose counterparties best describe what the user was doing (most user-facing first). */
private fun List<AuthPurpose>.primaryNamed(): AuthPurpose? =
listOf(AuthPurposeKind.SEND_DM, AuthPurposeKind.NOTIFY_INBOX, AuthPurposeKind.READ_OUTBOX)
.firstNotNullOfOrNull { kind -> firstOrNull { it.kind == kind && it.counterparties.isNotEmpty() } }
@Composable
private fun titleFor(
kind: AuthPurposeKind?,
who: String?,
): String =
when (kind) {
AuthPurposeKind.SEND_DM -> stringRes(R.string.relay_auth_title_send_dm, who ?: "")
AuthPurposeKind.NOTIFY_INBOX -> stringRes(R.string.relay_auth_title_notify, who ?: "")
AuthPurposeKind.READ_OUTBOX -> stringRes(R.string.relay_auth_title_read, who ?: "")
else -> stringRes(R.string.relay_auth_prompt_title)
}
@Composable
private fun consequenceFor(
kind: AuthPurposeKind?,
who: String?,
): String? =
when (kind) {
AuthPurposeKind.SEND_DM -> stringRes(R.string.relay_auth_consequence_send_dm, who ?: "")
AuthPurposeKind.NOTIFY_INBOX -> stringRes(R.string.relay_auth_consequence_notify, who ?: "")
AuthPurposeKind.READ_OUTBOX -> stringRes(R.string.relay_auth_consequence_read, who ?: "")
else -> null
}
/** A short label for a set of counterparties: the first person's name, or "Alice and others". */
@Composable
private fun counterpartyLabel(
pubkeys: Set<HexKey>,
accountViewModel: AccountViewModel,
): String {
val first = pubkeys.firstOrNull() ?: return ""
val name = rememberDisplayName(first, accountViewModel)
return if (pubkeys.size > 1) stringRes(R.string.relay_auth_name_and_others, name) else name
}
/** The best display name for [pubkey], reactive to metadata arriving from relays. */
@Composable
private fun rememberDisplayName(
pubkey: HexKey,
accountViewModel: AccountViewModel,
): String {
var user by remember(pubkey) { mutableStateOf(accountViewModel.getUserIfExists(pubkey)) }
if (user == null) {
LaunchedEffect(pubkey) { user = accountViewModel.checkGetOrCreateUser(pubkey) }
}
val loaded = user ?: return pubkey.take(8)
// Reading the observed metadata registers a snapshot read, so the name updates when it arrives.
val metadata by observeUserInfo(loaded, accountViewModel)
return metadata?.info?.bestName() ?: loaded.toBestDisplayName()
}
+11 -2
View File
@@ -829,8 +829,17 @@
<string name="relay_auth_policy_trusted_follows_desc">Also authenticate with relays that serve people you follow, such as sending a message to a friend. You\'ll be asked about anyone else.</string>
<string name="relay_auth_trust_reads">Also trust when reading their posts</string>
<string name="relay_auth_trust_reads_desc">Log in automatically to download posts from people you follow, not just to message or notify them.</string>
<string name="relay_auth_prompt_title">Log in to this relay?</string>
<string name="relay_auth_prompt_message">This relay asks you to log in before it will:</string>
<string name="relay_auth_prompt_title">Confirm it\'s you to this relay?</string>
<string name="relay_auth_prompt_message">This relay wants to confirm it\'s really you first. Its operator will see which account you are.</string>
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<string name="relay_auth_title_send_dm">Send your message to %1$s?</string>
<string name="relay_auth_title_notify">Notify %1$s?</string>
<string name="relay_auth_title_read">Load posts from %1$s?</string>
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="relay_auth_consequence_send_dm">If you don\'t, your message to %1$s won\'t be delivered.</string>
<string name="relay_auth_consequence_notify">If you don\'t, %1$s won\'t be notified about this.</string>
<string name="relay_auth_consequence_read">If you don\'t, you won\'t see posts from %1$s here.</string>
<string name="relay_auth_name_and_others">%1$s and others</string>
<string name="relay_auth_reason_send_dm">Send your private message to:</string>
<string name="relay_auth_reason_notify_inbox">Notify:</string>
<string name="relay_auth_reason_read_outbox">Download posts from:</string>