fix(relay-auth): say whose conversation the relay is holding back

The thread prompt read "It won't serve the rest of this conversation unless you
log in" without saying which conversation — and these prompts routinely surface
over the home feed or the settings screen, so "this" pointed at something the
reader did not have on screen at all.

The information was there and being discarded. Thread reads are `#e` against note
ids, which the assemblers either declare as `entityIds` or (ReactionsFilterAssembler)
only put in the filter's `e` tags; the deriver kept neither, emitting a bare
AuthPurpose(THREAD). It now carries those ids in a new `notes` field — separate
from `venues`, because a thread is not a room — and the dialog resolves them to
their authors through the same counterparty label the other purposes use, so the
sentence gets a name and an inline avatar.

"this conversation" survives only as the fallback for a thread whose notes are not
in the cache yet.

Verified on device: "It won't serve the rest of your conversation with Alice Peer
unless you log in."

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-12 13:46:18 -04:00
co-authored by Claude Opus 5
parent 88f81bbff9
commit 0e13a3b7a7
4 changed files with 42 additions and 4 deletions
@@ -149,7 +149,26 @@ private fun RelayAuthPromptDialog(
// The purpose the user is most likely to recognize as "what I was just doing".
val primary = remember(prompt) { prompt.purposes.primary() }
val faces = primary?.counterparties?.toList().orEmpty()
// A thread names nobody by itself, so the sentence used to say "the rest of this conversation"
// about something the reader may not have on screen — these prompts routinely surface over the
// home feed or the settings screen. Resolving the notes to their authors turns the vague
// reference into a person the reader recognizes.
val threadFaces =
remember(primary) {
if (primary?.kind == AuthPurposeKind.THREAD) {
primary.notes.mapNotNullTo(LinkedHashSet()) { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex }.toList()
} else {
emptyList()
}
}
val faces =
primary
?.counterparties
?.toList()
.orEmpty()
.ifEmpty { threadFaces }
val who =
when (primary?.kind) {
AuthPurposeKind.POST_VENUE, AuthPurposeKind.READ_VENUE ->
@@ -380,7 +399,10 @@ private fun reasonFor(
AuthPurposeKind.POST_VENUE -> stringRes(R.string.relay_auth_why_post_venue, who ?: "")
AuthPurposeKind.READ_VENUE -> stringRes(R.string.relay_auth_why_read_venue, who ?: "")
AuthPurposeKind.MY_INBOX -> stringRes(R.string.relay_auth_why_my_inbox)
AuthPurposeKind.THREAD -> stringRes(R.string.relay_auth_why_thread)
// Name the conversation by who is in it when we could resolve the notes; "this conversation"
// only survives as the fallback for a thread whose notes aren't in the cache yet.
AuthPurposeKind.THREAD ->
if (who.isNullOrBlank()) stringRes(R.string.relay_auth_why_thread) else stringRes(R.string.relay_auth_why_thread_with, who)
// No attributable purpose. If it is the user's own relay we can at least say that much,
// which is the only way MY_OWN_RELAY is ever reachable — the deriver is account-agnostic
// (one shared socket, many accounts) so it cannot know whose relay this is.
@@ -89,6 +89,7 @@ object RelayAuthPurposeDeriver {
val readAuthors = mutableSetOf<HexKey>()
val readVenues = mutableSetOf<String>()
val readThreadNotes = mutableSetOf<String>()
var readsMyInbox = false
var readsThread = false
var unattributedRead = false
@@ -99,7 +100,14 @@ object RelayAuthPurposeDeriver {
// Declared and self-contained: nobody else's identity is involved, so there is
// nothing to collect — the flag alone drives the wording.
AuthPurposeKind.MY_INBOX -> readsMyInbox = true
AuthPurposeKind.THREAD -> readsThread = true
// The notes are what makes the sentence nameable. The assembler either declares
// them (entityIds) or, as ReactionsFilterAssembler does, only puts them in the
// `e` tags — take whichever we get so the prompt can say whose conversation.
AuthPurposeKind.THREAD -> {
readsThread = true
explained?.entityIds?.let(readThreadNotes::addAll)
filter.tags?.get("e")?.let(readThreadNotes::addAll)
}
// Declared, but the *who*/*what* still comes from the filter. Prefer the entity
// ids the assembler named over sniffing tags, and fall back when it named none.
AuthPurposeKind.READ_VENUE -> {
@@ -135,7 +143,7 @@ object RelayAuthPurposeDeriver {
if (readAuthors.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.READ_OUTBOX, readAuthors))
if (readVenues.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.READ_VENUE, venues = readVenues))
if (readsMyInbox) add(AuthPurpose(AuthPurposeKind.MY_INBOX))
if (readsThread) add(AuthPurpose(AuthPurposeKind.THREAD))
if (readsThread) add(AuthPurpose(AuthPurposeKind.THREAD, notes = readThreadNotes))
// Safety net: we're using this relay but couldn't say how — prompt rather than fail silently.
if (isEmpty() && (unattributedWrite || unattributedRead)) add(AuthPurpose(AuthPurposeKind.OTHER))
}
+1
View File
@@ -1161,6 +1161,7 @@
<string name="relay_auth_why_read_venue">It won\'t serve %1$s unless you log in.</string>
<string name="relay_auth_why_my_inbox">It won\'t serve your incoming replies, zaps and messages unless you log in.</string>
<string name="relay_auth_why_thread">It won\'t serve the rest of this conversation unless you log in.</string>
<string name="relay_auth_why_thread_with">It won\'t serve the rest of your conversation with %1$s unless you log in.</string>
<string name="relay_auth_why_my_own_relay">This is one of your own relays, and it asks everyone to log in.</string>
<string name="relay_auth_why_other">It asks everyone to log in. Amethyst can\'t tell what it\'s holding back.</string>
<string name="relay_auth_someone_unloaded">someone you haven\'t loaded yet</string>
@@ -75,6 +75,13 @@ data class AuthPurpose(
val kind: AuthPurposeKind,
val counterparties: Set<String> = emptySet(),
val venues: Set<String> = emptySet(),
/**
* Event ids of the conversation being read, for [AuthPurposeKind.THREAD]. Kept separate from
* [venues] because a thread is not a room: these are note ids, and the only use for them is to
* resolve *whose* conversation it is at render time, so the prompt can say "your conversation
* with Alice" instead of "this conversation" about something the user may not have on screen.
*/
val notes: Set<String> = emptySet(),
)
/** The relay plus every live reason we currently have to auth with it. */