From a7f60082f027d151a49d8005165088dfd367de03 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 21:51:03 +0000 Subject: [PATCH] feat: add push notifications for reactions Add a new notification channel for reactions (kind 7 events) so users get notified when someone reacts to their posts. Follows the same pattern as zap notifications with deduplication, time-window filtering, and grouped summaries. https://claude.ai/code/session_01J9rAA4oeFEfNcYD6GoJqqt --- .../EventNotificationConsumer.kt | 75 +++++++++++++++++++ .../notifications/NotificationUtils.kt | 52 +++++++++++++ amethyst/src/main/res/values/strings.xml | 7 ++ 3 files changed, 134 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 d1696c702..42eb17aa8 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 @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.stringRes @@ -43,6 +44,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent @@ -106,6 +108,7 @@ class EventNotificationConsumer( when (innerEvent) { is PrivateDmEvent -> notify(innerEvent, account) is LnZapEvent -> notify(innerEvent, account) + is ReactionEvent -> notify(innerEvent, account) is ChatMessageEvent -> notify(innerEvent, account) is ChatMessageEncryptedFileHeaderEvent -> notify(innerEvent, account) } @@ -481,6 +484,78 @@ class EventNotificationConsumer( } } + private suspend fun notify( + event: ReactionEvent, + account: Account, + ) { + Log.d(TAG, "New Reaction to Notify") + + // old event being re-broadcast + if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return + + // don't notify for own reactions + if (event.pubKey == account.signer.pubKey) return + + // only notify if the reaction is for the current user + if (!event.isTaggedUser(account.signer.pubKey)) return + + val reactedPostId = event.originalPost().firstOrNull() ?: return + val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId) + + val author = LocalCache.getOrCreateUser(event.pubKey) + val user = author.toBestDisplayName() + val userPicture = author.profilePicture() + + val reactionContent = event.content + val reactionSymbol = + when { + reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19" + reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E" + else -> reactionContent + } + + val title = "$reactionSymbol $user" + + val reactedContent = + reactedNote + ?.event + ?.content + ?.split("\n") + ?.firstOrNull() ?: "" + + val content = + if (reactedContent.isNotBlank()) { + stringRes( + applicationContext, + R.string.app_notification_reactions_channel_message_for, + reactedContent, + ) + } else { + stringRes( + applicationContext, + R.string.app_notification_reactions_channel_message, + user, + ) + } + + val noteUri = + "notifications$ACCOUNT_QUERY_PARAM" + + account.signer.pubKey + .hexToByteArray() + .toNpub() + + notificationManager() + .sendReactionNotification( + event.id, + content, + title, + event.createdAt, + userPicture, + noteUri, + applicationContext, + ) + } + fun notificationManager(): NotificationManager = ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager 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 9c16a2e64..0ba91c622 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 @@ -45,8 +45,10 @@ import kotlinx.coroutines.withContext object NotificationUtils { private var dmChannel: NotificationChannel? = null private var zapChannel: NotificationChannel? = null + private var reactionChannel: 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" const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" const val KEY_REPLY_TEXT = "key_reply_text" @@ -56,6 +58,7 @@ object NotificationUtils { private const val DM_SUMMARY_ID = 0x10000 private const val ZAP_SUMMARY_ID = 0x20000 + private const val REACTION_SUMMARY_ID = 0x30000 fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { if (dmChannel != null) return dmChannel!! @@ -99,6 +102,55 @@ object NotificationUtils { return zapChannel!! } + fun getOrCreateReactionChannel(applicationContext: Context): NotificationChannel { + if (reactionChannel != null) return reactionChannel!! + + reactionChannel = + NotificationChannel( + stringRes(applicationContext, R.string.app_notification_reactions_channel_id), + stringRes(applicationContext, R.string.app_notification_reactions_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = + stringRes(applicationContext, R.string.app_notification_reactions_channel_description) + } + + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(reactionChannel!!) + + return reactionChannel!! + } + + suspend fun NotificationManager.sendReactionNotification( + id: String, + messageBody: String, + messageTitle: String, + time: Long, + pictureUrl: String?, + uri: String, + applicationContext: Context, + ) { + getOrCreateReactionChannel(applicationContext) + val channelId = stringRes(applicationContext, R.string.app_notification_reactions_channel_id) + + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + time = time, + pictureUrl = pictureUrl, + uri = uri, + channelId = channelId, + notificationGroupKey = REACTION_GROUP_KEY, + category = NotificationCompat.CATEGORY_SOCIAL, + summaryId = REACTION_SUMMARY_ID, + summaryText = stringRes(applicationContext, R.string.app_notification_reactions_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 c11be6263..12d9e1bad 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -751,6 +751,13 @@ New messages New zaps + ReactionsID + Reactions + Notifies you when somebody reacts to your post + %1$s reacted to your post + for %1$s + New reactions + Notify: Join Conversation