From a9e0e0fe3f5678f23773ddcf592c7fe714b58acc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 00:48:31 +0000 Subject: [PATCH] feat(notifications): notify on replies (NIP-10) and comments (NIP-22) Adds a dedicated "Replies" notification channel that fires when someone replies to one of the user's posts. Handled on two kinds: - TextNoteEvent (kind 1): notifies only when the reply target (`replyingTo()`) is authored by the current account, so plain p-tag mentions are not routed through this channel. - CommentEvent (kind 1111): notifies when the current account is the NIP-22 reply author or root author of the parent scope. The channel uses its own group key and summary so users can disable it independently from OS-level notification settings, just like Zaps, Reactions, DMs, and Chess. https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc --- .../EventNotificationConsumer.kt | 109 ++++++++++++++++++ .../notifications/NotificationDispatcher.kt | 4 + .../notifications/NotificationUtils.kt | 52 +++++++++ amethyst/src/main/res/values/strings.xml | 6 + 4 files changed, 171 insertions(+) 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 e40adf983..fa8b4e31a 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 @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.service.call.notification.CallNotifier import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReplyNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState @@ -56,10 +57,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -176,6 +179,8 @@ class EventNotificationConsumer( is ChatMessageEvent -> notify(event, account) is ChatMessageEncryptedFileHeaderEvent -> notify(event, account) is ReactionEvent -> notify(event, account) + is TextNoteEvent -> notify(event, account) + is CommentEvent -> notify(event, account) is LiveChessGameAcceptEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_challenge_accepted) is LiveChessMoveEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_your_turn) // WelcomeEvent is dispatched directly from processMarmotWelcomeFlow @@ -648,6 +653,110 @@ class EventNotificationConsumer( ) } + private suspend fun notify( + event: TextNoteEvent, + account: Account, + ) { + Log.d(TAG, "New TextNote to Notify") + + // old event being re-broadcast + if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return + + // 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 + + // 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 + + notifyReply(event, account, repliedNote.event?.content) + } + + private suspend fun notify( + event: CommentEvent, + account: Account, + ) { + Log.d(TAG, "New NIP-22 Comment to Notify") + + // old event being re-broadcast + if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return + + // don't notify for own comments + if (event.pubKey == account.signer.pubKey) return + + // NIP-22 marks direct-reply and root authors. Notify when the current + // account is either (someone commenting on our post, or replying to our comment). + val pubKey = account.signer.pubKey + val isTarget = event.replyAuthorKeys().contains(pubKey) || event.rootAuthorKeys().contains(pubKey) + if (!isTarget) return + + val parentContent = + event + .replyingTo() + ?.let { LocalCache.getNoteIfExists(it)?.event?.content } + + notifyReply(event, account, parentContent) + } + + private suspend fun notifyReply( + event: Event, + account: Account, + parentContent: String?, + ) { + val replyNote = 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_replies_channel_message, user) + + val replyExcerpt = + event.content + .split("\n") + .firstOrNull { it.isNotBlank() } + ?.take(280) + ?: "" + + val parentExcerpt = + parentContent + ?.split("\n") + ?.firstOrNull { it.isNotBlank() } + ?.take(140) + + val content = + if (!parentExcerpt.isNullOrBlank()) { + replyExcerpt + "\n\n" + + stringRes( + applicationContext, + R.string.app_notification_replies_channel_message_for, + parentExcerpt, + ) + } else { + replyExcerpt + } + + val accountNpub = + account.signer.pubKey + .hexToByteArray() + .toNpub() + val noteUri = replyNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub + + notificationManager() + .sendReplyNotification( + event.id, + content, + title, + event.createdAt, + userPicture, + noteUri, + applicationContext, + ) + } + private suspend fun notifyChessEvent( event: BaseChessEvent, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index f4ff5f82d..32900cc9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -28,8 +28,10 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent @@ -75,6 +77,8 @@ class NotificationDispatcher( PrivateDmEvent.KIND, LnZapEvent.KIND, ReactionEvent.KIND, + TextNoteEvent.KIND, + CommentEvent.KIND, LiveChessGameAcceptEvent.KIND, LiveChessMoveEvent.KIND, WakeUpEvent.KIND, 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 a3aeefddb..b1815d605 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 @@ -48,11 +48,13 @@ object NotificationUtils { private var zapChannel: NotificationChannel? = null private var reactionChannel: NotificationChannel? = null private var chessChannel: NotificationChannel? = null + private var replyChannel: 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_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" @@ -65,6 +67,7 @@ object NotificationUtils { 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 fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { if (dmChannel != null) return dmChannel!! @@ -150,6 +153,27 @@ object NotificationUtils { return chessChannel!! } + fun getOrCreateReplyChannel(applicationContext: Context): NotificationChannel { + if (replyChannel != null) return replyChannel!! + + replyChannel = + NotificationChannel( + stringRes(applicationContext, R.string.app_notification_replies_channel_id), + stringRes(applicationContext, R.string.app_notification_replies_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = + stringRes(applicationContext, R.string.app_notification_replies_channel_description) + } + + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(replyChannel!!) + + return replyChannel!! + } + suspend fun NotificationManager.sendReactionNotification( id: String, messageBody: String, @@ -206,6 +230,34 @@ object NotificationUtils { ) } + suspend fun NotificationManager.sendReplyNotification( + id: String, + messageBody: String, + messageTitle: String, + time: Long, + pictureUrl: String?, + uri: String, + applicationContext: Context, + ) { + getOrCreateReplyChannel(applicationContext) + val channelId = stringRes(applicationContext, R.string.app_notification_replies_channel_id) + + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + time = time, + pictureUrl = pictureUrl, + uri = uri, + channelId = channelId, + notificationGroupKey = REPLY_GROUP_KEY, + category = NotificationCompat.CATEGORY_SOCIAL, + summaryId = REPLY_SUMMARY_ID, + summaryText = stringRes(applicationContext, R.string.app_notification_replies_summary), + applicationContext = applicationContext, + ) + } + suspend fun NotificationManager.sendZapNotification( id: String, messageBody: String, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4bbb8f3e0..43745ce20 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -876,6 +876,12 @@ %1$s accepted your chess challenge %1$s moved — your turn Chess updates + RepliesID + Replies + Notifies you when somebody replies to your post + %1$s replied + on: %1$s + New replies Incoming calls