From cc68c2ae1f67ab19c44e8ff5b264ab095a63494b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 01:10:27 +0000 Subject: [PATCH] feat(notifications): mentions channel, per-thread grouping, inline reply - Split kind-1 routing in EventNotificationConsumer: if the reply target is authored by the current account it still lands on the Replies channel; any other p-tagged kind-1 (plain mention, citation, reply to someone else in a thread we're in) lands on a new dedicated Mentions channel. Users can disable either independently from Android settings. - Group reply notifications by thread root. Compute the root via the NIP-10 marked/unmarked root markers for kind 1 and the NIP-22 root event/address tags for kind 1111, falling back to the direct parent. Use a per-thread group key + stable per-thread summary id so Android collapses "5 replies to your thread" into one entry instead of one big flat stack. - Add an inline Reply action on reply notifications (kind 1 + kind 1111) using RemoteInput, mirroring the DM flow. NotificationReplyReceiver now handles PUBLIC_REPLY_ACTION: resolves the target event from LocalCache and signs a NIP-10 reply (TextNoteEvent.build with replyingTo hint) or a NIP-22 comment (CommentEvent.replyBuilder), broadcast via account.signAndComputeBroadcast. The relay proxy is held open for the duration via the same runOnRelay helper the DM flow uses. https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc --- .../EventNotificationConsumer.kt | 81 ++++++++++-- .../NotificationReplyReceiver.kt | 114 +++++++++++++---- .../notifications/NotificationUtils.kt | 120 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 5 + 4 files changed, 278 insertions(+), 42 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index fa8b4e31a..bc3c42c84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -38,8 +38,10 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.call.notification.CallNotifier +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.InlineReplyTarget import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendMentionNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReplyNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification @@ -665,14 +667,19 @@ class EventNotificationConsumer( // don't notify for own notes if (event.pubKey == account.signer.pubKey) return - // must be a reply (NIP-10) — plain mentions are not handled on this channel. - val replyTargetId = event.replyingTo() ?: return + val replyTargetId = event.replyingTo() - // only notify when the reply targets a note authored by the current account. - val repliedNote = LocalCache.getNoteIfExists(replyTargetId) ?: return - if (repliedNote.author?.pubkeyHex != account.signer.pubKey) return + if (replyTargetId != null) { + val repliedNote = LocalCache.getNoteIfExists(replyTargetId) + if (repliedNote?.author?.pubkeyHex == account.signer.pubKey) { + val threadRoot = event.markedRoot()?.eventId ?: event.unmarkedRoot()?.eventId ?: replyTargetId + notifyReply(event, account, repliedNote.event?.content, threadRoot) + return + } + } - notifyReply(event, account, repliedNote.event?.content) + // Not a reply to us but we're p-tagged — a mention or citation. + notifyMention(event, account) } private suspend fun notify( @@ -698,13 +705,20 @@ class EventNotificationConsumer( .replyingTo() ?.let { LocalCache.getNoteIfExists(it)?.event?.content } - notifyReply(event, account, parentContent) + val threadRoot = + event.rootEventIds().firstOrNull() + ?: event.rootAddressIds().firstOrNull() + ?: event.replyingToAddressOrEvent() + ?: event.id + + notifyReply(event, account, parentContent, threadRoot) } private suspend fun notifyReply( event: Event, account: Account, parentContent: String?, + threadRootId: String, ) { val replyNote = LocalCache.getNoteIfExists(event.id) ?: return @@ -747,13 +761,52 @@ class EventNotificationConsumer( notificationManager() .sendReplyNotification( - event.id, - content, - title, - event.createdAt, - userPicture, - noteUri, - applicationContext, + id = event.id, + messageBody = content, + messageTitle = title, + time = event.createdAt, + pictureUrl = userPicture, + uri = noteUri, + applicationContext = applicationContext, + threadRootId = threadRootId, + inlineReply = InlineReplyTarget(accountNpub = accountNpub, targetEventId = event.id), + ) + } + + private suspend fun notifyMention( + event: TextNoteEvent, + account: Account, + ) { + val note = LocalCache.getNoteIfExists(event.id) ?: return + + val author = LocalCache.getOrCreateUser(event.pubKey) + val user = author.toBestDisplayName() + val userPicture = author.profilePicture() + + val title = stringRes(applicationContext, R.string.app_notification_mentions_channel_message, user) + + val content = + event.content + .split("\n") + .firstOrNull { it.isNotBlank() } + ?.take(280) + ?: "" + + val accountNpub = + account.signer.pubKey + .hexToByteArray() + .toNpub() + val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub + + notificationManager() + .sendMentionNotification( + id = event.id, + messageBody = content, + messageTitle = title, + time = event.createdAt, + pictureUrl = userPicture, + uri = noteUri, + applicationContext = applicationContext, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index c51877b63..f8d8923af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -28,8 +28,12 @@ import androidx.core.app.RemoteInput import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -69,32 +73,56 @@ class NotificationReplyReceiver : BroadcastReceiver() { if (members.isEmpty()) return - val pendingResult = goAsync() - val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - scope.launch { - // activates the relay to send the message. - val collectionJob = - scope.launch { - Amethyst.instance.relayProxyClientConnector.relayServices - .collect() - } - - try { - sendReply(accountNpub, members, replyText) - notificationManager.cancel(notificationId) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("NotificationReply") { "Failed to send reply: ${e.message}" } - } finally { - pendingResult.finish() - - // closes the relay connection. - collectionJob.cancel() - scope.cancel() - } + runOnRelay(notificationManager, notificationId) { + sendReply(accountNpub, members, replyText) } } + + NotificationUtils.PUBLIC_REPLY_ACTION -> { + val replyText = + RemoteInput + .getResultsFromIntent(intent) + ?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT) + ?.toString() + + if (replyText.isNullOrBlank()) return + + val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return + val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return + + runOnRelay(notificationManager, notificationId) { + sendPublicReply(accountNpub, targetEventId, replyText) + } + } + } + } + + private fun runOnRelay( + notificationManager: NotificationManager, + notificationId: Int, + block: suspend () -> Unit, + ) { + val pendingResult = goAsync() + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + scope.launch { + val collectionJob = + scope.launch { + Amethyst.instance.relayProxyClientConnector.relayServices + .collect() + } + + try { + block() + notificationManager.cancel(notificationId) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NotificationReply") { "Failed to send reply: ${e.message}" } + } finally { + pendingResult.finish() + collectionJob.cancel() + scope.cancel() + } } } @@ -111,4 +139,42 @@ class NotificationReplyReceiver : BroadcastReceiver() { account.sendNip17PrivateMessage(template) } + + private suspend fun sendPublicReply( + accountNpub: String, + targetEventId: String, + replyText: String, + ) { + val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return + val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) + + val targetEvent = LocalCache.getNoteIfExists(targetEventId)?.event ?: return + + val template = + when (targetEvent) { + is TextNoteEvent -> { + TextNoteEvent.build( + note = replyText, + replyingTo = EventHintBundle(targetEvent), + ) + } + + is CommentEvent -> { + CommentEvent.replyBuilder( + msg = replyText, + replyingTo = EventHintBundle(targetEvent), + ) + } + + else -> { + // Non-threaded events (e.g. long-form articles) use NIP-22 comments. + CommentEvent.replyBuilder( + msg = replyText, + replyingTo = EventHintBundle(targetEvent), + ) + } + } + + account.signAndComputeBroadcast(template) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index b1815d605..7ff9d453f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -49,25 +49,39 @@ object NotificationUtils { private var reactionChannel: NotificationChannel? = null private var chessChannel: NotificationChannel? = null private var replyChannel: NotificationChannel? = null + private var mentionChannel: NotificationChannel? = null private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION" private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION" - private const val REPLY_GROUP_KEY = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION" + const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION" + private const val MENTION_GROUP_KEY = "com.vitorpamplona.amethyst.MENTION_NOTIFICATION" const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" + const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" const val KEY_REPLY_TEXT = "key_reply_text" const val KEY_NOTIFICATION_ID = "key_notification_id" const val KEY_ACCOUNT_NPUB = "key_account_npub" const val KEY_CHATROOM_MEMBERS = "key_chatroom_members" + const val KEY_TARGET_EVENT_ID = "key_target_event_id" private const val DM_SUMMARY_ID = 0x10000 private const val ZAP_SUMMARY_ID = 0x20000 private const val REACTION_SUMMARY_ID = 0x40000 private const val CHESS_SUMMARY_ID = 0x30000 - private const val REPLY_SUMMARY_ID = 0x50000 + private const val REPLY_SUMMARY_ID_BASE = 0x50000 + private const val MENTION_SUMMARY_ID = 0x60000 + + /** + * Derives a stable summary notification id for a per-thread reply group. + * Uses the thread root id hash mixed with the base id so different threads + * don't collide with each other or with the other channel summaries. + */ + fun replySummaryIdFor(threadRootId: String): Int = REPLY_SUMMARY_ID_BASE xor threadRootId.hashCode() + + fun replyGroupKeyFor(threadRootId: String): String = "$REPLY_GROUP_KEY_PREFIX:$threadRootId" fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { if (dmChannel != null) return dmChannel!! @@ -174,6 +188,27 @@ object NotificationUtils { return replyChannel!! } + fun getOrCreateMentionChannel(applicationContext: Context): NotificationChannel { + if (mentionChannel != null) return mentionChannel!! + + mentionChannel = + NotificationChannel( + stringRes(applicationContext, R.string.app_notification_mentions_channel_id), + stringRes(applicationContext, R.string.app_notification_mentions_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = + stringRes(applicationContext, R.string.app_notification_mentions_channel_description) + } + + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(mentionChannel!!) + + return mentionChannel!! + } + suspend fun NotificationManager.sendReactionNotification( id: String, messageBody: String, @@ -238,6 +273,8 @@ object NotificationUtils { pictureUrl: String?, uri: String, applicationContext: Context, + threadRootId: String, + inlineReply: InlineReplyTarget? = null, ) { getOrCreateReplyChannel(applicationContext) val channelId = stringRes(applicationContext, R.string.app_notification_replies_channel_id) @@ -250,14 +287,54 @@ object NotificationUtils { pictureUrl = pictureUrl, uri = uri, channelId = channelId, - notificationGroupKey = REPLY_GROUP_KEY, + notificationGroupKey = replyGroupKeyFor(threadRootId), category = NotificationCompat.CATEGORY_SOCIAL, - summaryId = REPLY_SUMMARY_ID, + summaryId = replySummaryIdFor(threadRootId), summaryText = stringRes(applicationContext, R.string.app_notification_replies_summary), applicationContext = applicationContext, + inlineReply = inlineReply, + ) + } + + suspend fun NotificationManager.sendMentionNotification( + id: String, + messageBody: String, + messageTitle: String, + time: Long, + pictureUrl: String?, + uri: String, + applicationContext: Context, + ) { + getOrCreateMentionChannel(applicationContext) + val channelId = stringRes(applicationContext, R.string.app_notification_mentions_channel_id) + + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + time = time, + pictureUrl = pictureUrl, + uri = uri, + channelId = channelId, + notificationGroupKey = MENTION_GROUP_KEY, + category = NotificationCompat.CATEGORY_SOCIAL, + summaryId = MENTION_SUMMARY_ID, + summaryText = stringRes(applicationContext, R.string.app_notification_mentions_summary), + applicationContext = applicationContext, ) } + /** + * Payload for wiring a RemoteInput-powered inline reply action onto a public + * note notification. The receiver resolves the target event from LocalCache + * via [targetEventId] and signs the appropriate kind (1 for NIP-10, 1111 + * for NIP-22) under the account identified by [accountNpub]. + */ + data class InlineReplyTarget( + val accountNpub: String, + val targetEventId: String, + ) + suspend fun NotificationManager.sendZapNotification( id: String, messageBody: String, @@ -490,6 +567,7 @@ object NotificationUtils { summaryId: Int, summaryText: String, applicationContext: Context, + inlineReply: InlineReplyTarget? = null, ) { val notId = id.hashCode() @@ -536,6 +614,40 @@ object NotificationUtils { .setAutoCancel(true) .setWhen(time * 1000) + if (inlineReply != null) { + val remoteInput = + RemoteInput + .Builder(KEY_REPLY_TEXT) + .setLabel(stringRes(applicationContext, R.string.app_notification_reply_label)) + .build() + + val replyIntent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + action = PUBLIC_REPLY_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_ACCOUNT_NPUB, inlineReply.accountNpub) + putExtra(KEY_TARGET_EVENT_ID, inlineReply.targetEventId) + } + + val replyPendingIntent = + PendingIntent.getBroadcast( + applicationContext, + notId, + replyIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val replyAction = + NotificationCompat.Action + .Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent) + .addRemoteInput(remoteInput) + .setAllowGeneratedReplies(true) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .build() + + builder.addAction(replyAction) + } + notify(notId, builder.build()) sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 43745ce20..c7446c01b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -882,6 +882,11 @@ %1$s replied on: %1$s New replies + MentionsID + Mentions + Notifies you when somebody mentions or cites you in a post + %1$s mentioned you + New mentions Incoming calls