fix: attribute reply-notification parent to its real author, not always "Me"

The parent message shown above a reply in the MessagingStyle notification was
unconditionally labeled "Me" with the account's avatar. That's wrong whenever
the account is notified as the *root* author of a thread but the direct parent
belongs to someone else — e.g. Vitor starts a thread, fiatjaf replies, a third
person replies to fiatjaf: fiatjaf's note was rendered as Vitor. This surfaced
through the NIP-22 comment and public-chat reply paths, which notify on
root-authorship, not just direct-parent authorship.

ReplyNotification now receives the parent Note (not a bare content string) and
resolves its actual author: attributed to the MessagingStyle `me` Person only
when the parent truly is the account's, otherwise shown as the real author with
their name + avatar, observed for enrichment like the replier. postConversation
reuses the `me` Person for a self-authored parent so it still renders as you.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
This commit is contained in:
Claude
2026-07-24 03:45:49 +00:00
parent 6df7398d02
commit d91c15d97b
3 changed files with 46 additions and 20 deletions
@@ -286,7 +286,7 @@ class EventNotificationConsumer(
val repliedNote = LocalCache.getNoteIfExists(replyTargetId)
if (repliedNote?.author?.pubkeyHex == account.signer.pubKey) {
val threadRoot = event.markedRoot()?.eventId ?: event.unmarkedRoot()?.eventId ?: replyTargetId
ReplyNotification.notify(applicationContext, account, event, repliedNote.event?.content, threadRoot)
ReplyNotification.notify(applicationContext, account, event, repliedNote, threadRoot)
return
}
}
@@ -301,14 +301,14 @@ class EventNotificationConsumer(
val isTarget = event.replyAuthorKeys().contains(pubKey) || event.rootAuthorKeys().contains(pubKey)
if (!isTarget) return
val parentContent = event.replyingTo()?.let { LocalCache.getNoteIfExists(it)?.event?.content }
val parentNote = event.replyingTo()?.let { LocalCache.getNoteIfExists(it) }
val threadRoot =
event.rootEventIds().firstOrNull()
?: event.rootAddressIds().firstOrNull()
?: event.replyingToAddressOrEvent()
?: event.id
ReplyNotification.notify(applicationContext, account, event, parentContent, threadRoot)
ReplyNotification.notify(applicationContext, account, event, parentNote, threadRoot)
}
private suspend fun notifyChannelMessage(
@@ -318,13 +318,9 @@ class EventNotificationConsumer(
val note = LocalCache.getNoteIfExists(event.id) ?: return
if (NotificationFeedFilter.isNotifiablePublicChatReply(note, account.signer.pubKey)) {
val parentContent =
note.replyTo
?.lastOrNull()
?.event
?.content
val parentNote = note.replyTo?.lastOrNull()
val threadRoot = event.channelId() ?: event.id
ReplyNotification.notify(applicationContext, account, event, parentContent, threadRoot)
ReplyNotification.notify(applicationContext, account, event, parentNote, threadRoot)
} else {
MentionNotification.notify(applicationContext, account, event)
}
@@ -152,6 +152,13 @@ object NotificationUtils {
val senderName: String,
val body: String,
val pictureUrl: String?,
/**
* True when the parent was authored by the logged-in account, so it is
* attributed to the MessagingStyle `me` Person (avatar + "you") rather than
* shown as a separate participant. False for a third party's note — e.g. a
* reply to someone else's reply in a thread the account started.
*/
val isFromMe: Boolean = false,
)
// ---------------------------------------------------------------------
@@ -284,13 +291,19 @@ object NotificationUtils {
val messagingStyle = NotificationCompat.MessagingStyle(me)
if (parent != null) {
val parentAvatar = parent.pictureUrl?.let { loadBitmap(it, applicationContext) }?.let { circleCrop(it) }
val parentSender =
Person
.Builder()
.setName(parent.senderName)
.apply { parentAvatar?.let { setIcon(IconCompat.createWithBitmap(it)) } }
.build()
if (parent.isFromMe) {
// The account authored the parent — reuse the `me` Person so it
// renders as self with the account's avatar, not a stranger.
me
} else {
val parentAvatar = parent.pictureUrl?.let { loadBitmap(it, applicationContext) }?.let { circleCrop(it) }
Person
.Builder()
.setName(parent.senderName)
.apply { parentAvatar?.let { setIcon(IconCompat.createWithBitmap(it)) } }
.build()
}
messagingStyle.addMessage(parent.body, (time - 1) * 1000, parentSender)
}
messagingStyle.addMessage(messageBody, time * 1000, sender)
@@ -24,6 +24,7 @@ import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
@@ -51,7 +52,7 @@ object ReplyNotification {
context: Context,
account: Account,
event: Event,
parentContent: String?,
parentNote: Note?,
threadRootId: String,
) {
val replyNote = LocalCache.getNoteIfExists(event.id) ?: return
@@ -63,17 +64,27 @@ object ReplyNotification {
val citedUsers = NotificationContent.resolveMentions(event.content).citedUsers
// The parent may be the account's own note (a direct reply) OR a third
// party's — e.g. someone replying to another reply in a thread the account
// started. Attribute it to whoever actually wrote it, and observe that
// author so their name/avatar fill in like the replier's do.
val parentContent = parentNote?.event?.content
val parentAuthor = parentNote?.author
val parentIsFromMe = parentAuthor?.pubkeyHex == account.signer.pubKey
val observedParentAuthor = parentAuthor?.takeUnless { parentIsFromMe }
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
context = context,
account = account,
notificationId = event.id,
users = listOf(author) + citedUsers,
users = listOf(author) + citedUsers + listOfNotNull(observedParentAuthor),
notes = listOf(replyNote),
isComplete = {
author.metadataOrNull()?.bestName() != null &&
citedUsers.all { it.metadataOrNull()?.bestName() != null }
citedUsers.all { it.metadataOrNull()?.bestName() != null } &&
(observedParentAuthor == null || observedParentAuthor.metadataOrNull()?.bestName() != null)
},
) {
val user = author.toBestDisplayName()
@@ -82,9 +93,15 @@ object ReplyNotification {
val parent =
parentExcerpt?.let {
ParentMessage(
senderName = stringRes(context, R.string.app_notification_me),
senderName =
if (parentIsFromMe || parentAuthor == null) {
stringRes(context, R.string.app_notification_me)
} else {
parentAuthor.toBestDisplayName()
},
body = it,
pictureUrl = account.userProfile().profilePicture(),
pictureUrl = if (parentIsFromMe) account.userProfile().profilePicture() else parentAuthor?.profilePicture(),
isFromMe = parentIsFromMe,
)
}
nm.postConversation(