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
This commit is contained in:
+109
@@ -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.sendChessNotification
|
||||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification
|
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.notifications.NotificationUtils.sendZapNotification
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount
|
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
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.isTaggedUser
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
||||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
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.files.ChatMessageEncryptedFileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
|
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
|
||||||
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||||
@@ -176,6 +179,8 @@ class EventNotificationConsumer(
|
|||||||
is ChatMessageEvent -> notify(event, account)
|
is ChatMessageEvent -> notify(event, account)
|
||||||
is ChatMessageEncryptedFileHeaderEvent -> notify(event, account)
|
is ChatMessageEncryptedFileHeaderEvent -> notify(event, account)
|
||||||
is ReactionEvent -> 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 LiveChessGameAcceptEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_challenge_accepted)
|
||||||
is LiveChessMoveEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_your_turn)
|
is LiveChessMoveEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_your_turn)
|
||||||
// WelcomeEvent is dispatched directly from processMarmotWelcomeFlow
|
// 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(
|
private suspend fun notifyChessEvent(
|
||||||
event: BaseChessEvent,
|
event: BaseChessEvent,
|
||||||
account: Account,
|
account: Account,
|
||||||
|
|||||||
+4
@@ -28,8 +28,10 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
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.files.ChatMessageEncryptedFileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||||
@@ -75,6 +77,8 @@ class NotificationDispatcher(
|
|||||||
PrivateDmEvent.KIND,
|
PrivateDmEvent.KIND,
|
||||||
LnZapEvent.KIND,
|
LnZapEvent.KIND,
|
||||||
ReactionEvent.KIND,
|
ReactionEvent.KIND,
|
||||||
|
TextNoteEvent.KIND,
|
||||||
|
CommentEvent.KIND,
|
||||||
LiveChessGameAcceptEvent.KIND,
|
LiveChessGameAcceptEvent.KIND,
|
||||||
LiveChessMoveEvent.KIND,
|
LiveChessMoveEvent.KIND,
|
||||||
WakeUpEvent.KIND,
|
WakeUpEvent.KIND,
|
||||||
|
|||||||
+52
@@ -48,11 +48,13 @@ object NotificationUtils {
|
|||||||
private var zapChannel: NotificationChannel? = null
|
private var zapChannel: NotificationChannel? = null
|
||||||
private var reactionChannel: NotificationChannel? = null
|
private var reactionChannel: NotificationChannel? = null
|
||||||
private var chessChannel: 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 DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
||||||
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_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 REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
|
||||||
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_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 REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
|
||||||
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_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 ZAP_SUMMARY_ID = 0x20000
|
||||||
private const val REACTION_SUMMARY_ID = 0x40000
|
private const val REACTION_SUMMARY_ID = 0x40000
|
||||||
private const val CHESS_SUMMARY_ID = 0x30000
|
private const val CHESS_SUMMARY_ID = 0x30000
|
||||||
|
private const val REPLY_SUMMARY_ID = 0x50000
|
||||||
|
|
||||||
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||||
if (dmChannel != null) return dmChannel!!
|
if (dmChannel != null) return dmChannel!!
|
||||||
@@ -150,6 +153,27 @@ object NotificationUtils {
|
|||||||
return chessChannel!!
|
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(
|
suspend fun NotificationManager.sendReactionNotification(
|
||||||
id: String,
|
id: String,
|
||||||
messageBody: 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(
|
suspend fun NotificationManager.sendZapNotification(
|
||||||
id: String,
|
id: String,
|
||||||
messageBody: String,
|
messageBody: String,
|
||||||
|
|||||||
@@ -876,6 +876,12 @@
|
|||||||
<string name="app_notification_chess_challenge_accepted">%1$s accepted your chess challenge</string>
|
<string name="app_notification_chess_challenge_accepted">%1$s accepted your chess challenge</string>
|
||||||
<string name="app_notification_chess_your_turn">%1$s moved — your turn</string>
|
<string name="app_notification_chess_your_turn">%1$s moved — your turn</string>
|
||||||
<string name="app_notification_chess_summary">Chess updates</string>
|
<string name="app_notification_chess_summary">Chess updates</string>
|
||||||
|
<string name="app_notification_replies_channel_id" translatable="false">RepliesID</string>
|
||||||
|
<string name="app_notification_replies_channel_name">Replies</string>
|
||||||
|
<string name="app_notification_replies_channel_description">Notifies you when somebody replies to your post</string>
|
||||||
|
<string name="app_notification_replies_channel_message">%1$s replied</string>
|
||||||
|
<string name="app_notification_replies_channel_message_for">on: %1$s</string>
|
||||||
|
<string name="app_notification_replies_summary">New replies</string>
|
||||||
|
|
||||||
<!-- Call notifications and UI -->
|
<!-- Call notifications and UI -->
|
||||||
<string name="app_notification_calls_channel_name">Incoming calls</string>
|
<string name="app_notification_calls_channel_name">Incoming calls</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user