From 0e13a3b7a7a7f3c7eac182de1fa6ac5590502518 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Aug 2026 13:46:18 -0400 Subject: [PATCH] fix(relay-auth): say whose conversation the relay is holding back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../compose/RelayAuthPromptHost.kt | 26 +++++++++++++++++-- .../model/RelayAuthPurposeDeriver.kt | 12 +++++++-- amethyst/src/main/res/values/strings.xml | 1 + .../amethyst/commons/relayauth/AuthPurpose.kt | 7 +++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt index 8f45f7eb43..36edeebf68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt index f044c521b4..d6bad70abc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt @@ -89,6 +89,7 @@ object RelayAuthPurposeDeriver { val readAuthors = mutableSetOf() val readVenues = mutableSetOf() + val readThreadNotes = mutableSetOf() 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)) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 909decbeb2..6231d8129d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1161,6 +1161,7 @@ It won\'t serve %1$s unless you log in. It won\'t serve your incoming replies, zaps and messages unless you log in. It won\'t serve the rest of this conversation unless you log in. + It won\'t serve the rest of your conversation with %1$s unless you log in. This is one of your own relays, and it asks everyone to log in. It asks everyone to log in. Amethyst can\'t tell what it\'s holding back. someone you haven\'t loaded yet diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt index cdf4166d4c..022ac62e03 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt @@ -75,6 +75,13 @@ data class AuthPurpose( val kind: AuthPurposeKind, val counterparties: Set = emptySet(), val venues: Set = 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 = emptySet(), ) /** The relay plus every live reason we currently have to auth with it. */