Merge pull request #2538 from vitorpamplona/claude/add-reply-notifications-GvMOn

Add reply and mention notifications for public notes
This commit is contained in:
Vitor Pamplona
2026-04-24 13:27:14 -04:00
committed by GitHub
11 changed files with 857 additions and 269 deletions
@@ -384,14 +384,20 @@ object LocalCache : ILocalCache, ICacheProvider {
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED) }.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
/** /**
* Emits each new event that matches the filter, one at a time, as it is * Emits each new event for which [predicate] returns true, one at a time,
* inserted into the cache. Unlike [observeEvents], this does not accumulate * as it is inserted into the cache. Unlike [observeEvents], this does not
* a list — useful for per-event reactive pipelines like notifications. * accumulate a list — useful for per-event reactive pipelines like
* notifications.
*
* The predicate runs on every insertion, so keep it cheap. Callers with a
* Nostr [Filter] can pass `filter::match`; compose additional local checks
* (rolling windows, derived fields the Filter grammar can't express) with
* `&&`.
*/ */
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> = fun <T : Event> observeNewEvents(predicate: (Event) -> Boolean): Flow<T> =
callbackFlow { callbackFlow {
val newFilter = val newFilter =
NewEventMatchingFilter<T>(filter) { NewEventMatchingFilter<T>(predicate) {
trySend(it) trySend(it)
} }
@@ -402,6 +408,8 @@ object LocalCache : ILocalCache, ICacheProvider {
} }
} }
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> = observeNewEvents(filter::match)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() } fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
@@ -37,9 +37,12 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier 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.sendChessNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification 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.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
@@ -51,20 +54,34 @@ import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent 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.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner 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.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.bech32.bechToBytes
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.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -129,15 +146,15 @@ class EventNotificationConsumer(
if (!notificationManager().areNotificationsEnabled()) return@withWakeLock if (!notificationManager().areNotificationsEnabled()) return@withWakeLock
val taggedNpubs = // Ask the event which of our signing accounts it's notifying.
event // Each kind declares its own notification semantics in
.taggedUserIds() // [Event.notifies] (lowercase `p` by default, NIP-22 also checks
.mapTo(mutableSetOf()) { LocalCache.getOrCreateUser(it).pubkeyNpub() } // uppercase `P`, etc.), so we don't hard-code tag names here.
if (taggedNpubs.isEmpty()) return@withWakeLock
LocalPreferences.allSavedAccounts().forEach { savedAccount -> LocalPreferences.allSavedAccounts().forEach { savedAccount ->
if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach
if (savedAccount.npub !in taggedNpubs) return@forEach
val accountHex = npubToHexOrNull(savedAccount.npub) ?: return@forEach
if (!event.notifies(accountHex)) return@forEach
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach
try { try {
@@ -150,11 +167,19 @@ class EventNotificationConsumer(
} }
} }
private fun npubToHexOrNull(npub: String): String? =
runCatching { npub.bechToBytes("npub").toHexKey() }
.onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } }
.getOrNull()
private suspend fun dispatchForAccount( private suspend fun dispatchForAccount(
event: Event, event: Event,
account: Account, account: Account,
) { ) {
// Calls and wake-ups are high-priority and always notify, even when MainActivity is visible. // Calls and wake-ups are high-priority and always notify, even when MainActivity is visible.
// They have their own freshness rules (CallManager.MAX_EVENT_AGE_SECONDS = 20s) and
// author-identity semantics (caller pubkey is the other party), so they bypass the
// shared gates below.
when (event) { when (event) {
is CallOfferEvent -> { is CallOfferEvent -> {
notifyIncomingCall(event, account) notifyIncomingCall(event, account)
@@ -170,13 +195,45 @@ class EventNotificationConsumer(
// Everything else is suppressed while the user is actively on the home screen. // Everything else is suppressed while the user is actively on the home screen.
if (MainActivity.isResumed) return if (MainActivity.isResumed) return
// Shared per-account gate: don't push-notify events this account authored.
// Applied here (not at the observer) because in a multi-account session
// account A's outgoing event legitimately becomes account B's incoming
// notification on the same device. The observer already enforces the
// 15-min rolling age window, so individual notify() methods don't need
// to repeat either check.
if (event.pubKey == account.signer.pubKey) return
when (event) { when (event) {
is PrivateDmEvent -> notify(event, account) is PrivateDmEvent -> notify(event, account)
is LnZapEvent -> notify(event, account) is LnZapEvent -> notify(event, account)
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 PictureEvent,
is VideoNormalEvent,
is VideoShortEvent,
is VideoHorizontalEvent,
is VideoVerticalEvent,
is ChannelMessageEvent,
is PollEvent,
is GitPatchEvent,
is GitIssueEvent,
is HighlightEvent,
is LongTextNoteEvent,
is WikiNoteEvent,
-> notifyMention(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
// (no `p` tag, so tag-based matching doesn't work). // (no `p` tag, so tag-based matching doesn't work).
@@ -257,50 +314,42 @@ class EventNotificationConsumer(
account: Account, account: Account,
) { ) {
Log.d(TAG, "New ChatMessage File to Notify") Log.d(TAG, "New ChatMessage File to Notify")
if ( // Age + self-author gates run centrally in dispatchForAccount.
// old event being re-broadcasted val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
event.createdAt > TimeUtils.fifteenMinutesAgo() && val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
// don't display if it comes from me. val chatRoom = event.chatroomKey(account.signer.pubKey)
event.pubKey != account.signer.pubKey
) { // from the user
Log.d(TAG, "Notifying")
val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
val chatRoom = event.chatroomKey(account.signer.pubKey)
val followingKeySet = account.followingKeySet() val followingKeySet = account.followingKeySet()
val isKnownRoom = val isKnownRoom =
( chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true ||
chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) chatroomList.hasSentMessagesTo(chatRoom)
)
if (isKnownRoom) { if (!isKnownRoom) return
val content = chatNote.event?.content ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val accountNpub =
account.signer.pubKey
.hexToByteArray()
.toNpub()
val chatroomMembers = chatRoom.users.joinToString(",")
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
notificationManager() val content = chatNote.event?.content ?: ""
.sendDMNotification( val user = chatNote.author?.toBestDisplayName() ?: ""
event.id, val userPicture = chatNote.author?.profilePicture()
content, val accountNpub =
user, account.signer.pubKey
event.createdAt, .hexToByteArray()
userPicture, .toNpub()
noteUri, val chatroomMembers = chatRoom.users.joinToString(",")
applicationContext, val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(), notificationManager()
chatroomMembers = chatroomMembers, .sendDMNotification(
) event.id,
} content,
} user,
event.createdAt,
userPicture,
noteUri,
applicationContext,
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = chatroomMembers,
)
} }
private suspend fun notify( private suspend fun notify(
@@ -308,47 +357,42 @@ class EventNotificationConsumer(
account: Account, account: Account,
) { ) {
Log.d(TAG, "New ChatMessage to Notify") Log.d(TAG, "New ChatMessage to Notify")
if ( // Age + self-author gates run centrally in dispatchForAccount.
// old event being re-broadcasted val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
event.createdAt > TimeUtils.fifteenMinutesAgo() && val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
// don't display if it comes from me. val chatRoom = event.chatroomKey(account.signer.pubKey)
event.pubKey != account.signer.pubKey
) { // from the user
Log.d(TAG, "Notifying")
val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
val chatRoom = event.chatroomKey(account.signer.pubKey)
val followingKeySet = account.followingKeySet() val followingKeySet = account.followingKeySet()
val isKnownRoom = chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) val isKnownRoom =
chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true ||
chatroomList.hasSentMessagesTo(chatRoom)
if (isKnownRoom) { if (!isKnownRoom) return
val content = chatNote.event?.content ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val accountNpub =
account.signer.pubKey
.hexToByteArray()
.toNpub()
val chatroomMembers = chatRoom.users.joinToString(",")
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
notificationManager() val content = chatNote.event?.content ?: ""
.sendDMNotification( val user = chatNote.author?.toBestDisplayName() ?: ""
id = event.id, val userPicture = chatNote.author?.profilePicture()
messageBody = content, val accountNpub =
senderName = user, account.signer.pubKey
time = event.createdAt, .hexToByteArray()
pictureUrl = userPicture, .toNpub()
uri = noteUri, val chatroomMembers = chatRoom.users.joinToString(",")
applicationContext = applicationContext, val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(), notificationManager()
chatroomMembers = chatroomMembers, .sendDMNotification(
) id = event.id,
} messageBody = content,
} senderName = user,
time = event.createdAt,
pictureUrl = userPicture,
uri = noteUri,
applicationContext = applicationContext,
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = chatroomMembers,
)
} }
private suspend fun notify( private suspend fun notify(
@@ -356,47 +400,48 @@ class EventNotificationConsumer(
account: Account, account: Account,
) { ) {
Log.d(TAG, "New Nip-04 DM to Notify") Log.d(TAG, "New Nip-04 DM to Notify")
// old event being re-broadcast // Age + self-author gates run centrally in dispatchForAccount. The
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return // dispatchForAccount self-check (event.pubKey != account.signer.pubKey)
// also covers the "don't notify myself about DMs I sent" case that
// was previously implicit via the recipient match below.
if (account.signer.pubKey != event.verifiedRecipientPubKey()) return
if (account.signer.pubKey == event.verifiedRecipientPubKey()) { val note = LocalCache.getNoteIfExists(event.id) ?: return
val note = LocalCache.getNoteIfExists(event.id) ?: return val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
val followingKeySet = account.followingKeySet() val followingKeySet = account.followingKeySet()
val chatRoom = event.chatroomKey(account.signer.pubKey) val chatRoom = event.chatroomKey(account.signer.pubKey)
val isKnownRoom = chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom) val isKnownRoom =
chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true ||
chatroomList.hasSentMessagesTo(chatRoom)
if (isKnownRoom) { if (!isKnownRoom) return
note.author?.let {
decryptContent(note, account.signer)?.let { content ->
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val accountNpub =
account.signer.pubKey
.hexToByteArray()
.toNpub()
val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
notificationManager() val author = note.author ?: return
.sendDMNotification( val content = decryptContent(note, account.signer) ?: return
id = event.id, val user = author.toBestDisplayName()
messageBody = content, val userPicture = author.profilePicture()
senderName = user, val accountNpub =
time = event.createdAt, account.signer.pubKey
pictureUrl = userPicture, .hexToByteArray()
uri = noteUri, .toNpub()
applicationContext = applicationContext, val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(), notificationManager()
chatroomMembers = null, .sendDMNotification(
) id = event.id,
} messageBody = content,
} senderName = user,
} time = event.createdAt,
} pictureUrl = userPicture,
uri = noteUri,
applicationContext = applicationContext,
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = null,
)
} }
/** /**
@@ -488,13 +533,10 @@ class EventNotificationConsumer(
Log.d(TAG) { "Notify Start ${event.toNostrUri()}" } Log.d(TAG) { "Notify Start ${event.toNostrUri()}" }
LocalCache.getNoteIfExists(event.id) ?: return LocalCache.getNoteIfExists(event.id) ?: return
Log.d(TAG, "Notify Not Notified Yet") // Age + self-author gates run centrally in dispatchForAccount. For zaps
// the self-check is effectively a no-op (receipts are signed by the LN
// old event being re-broadcast // service, not the zapper) but the uniform rule is cheap and keeps the
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return // downstream invariants simple.
Log.d(TAG, "Notify Not an old event")
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
@@ -504,79 +546,44 @@ class EventNotificationConsumer(
Log.d(TAG, "Notify Amount Bigger than 10") Log.d(TAG, "Notify Amount Bigger than 10")
if (event.isTaggedUser(account.signer.pubKey)) { // Zap routing (recipient == account) is enforced by the dispatcher
val amount = showAmount(event.amount) // predicate + consumeFromCache via Event.notifies; no re-check here.
val amount = showAmount(event.amount)
Log.d(TAG) { "Notify Amount $amount" } Log.d(TAG) { "Notify Amount $amount" }
(noteZapRequest.event as? LnZapRequestEvent)?.let { event -> (noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent -> decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent ->
Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" } Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" }
val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey) val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey)
val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null }) val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null })
if (noteZapped.event?.content != null) { if (noteZapped.event?.content != null) {
decryptContent(noteZapped, account.signer)?.let { decrypted -> decryptContent(noteZapped, account.signer)?.let { decrypted ->
Log.d(TAG, "Notify Decrypted if Private Note") Log.d(TAG, "Notify Decrypted if Private Note")
val zappedContent = decrypted.split("\n")[0] val zappedContent = decrypted.split("\n")[0]
val user = senderInfo.first.toBestDisplayName()
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
var content =
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_from,
user,
)
zappedContent.let {
content +=
" " +
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_for,
zappedContent,
)
}
val userPicture = senderInfo.first.profilePicture()
val noteUri =
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
.toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
notificationManager()
.sendZapNotification(
event.id,
content,
title,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
}
} else {
// doesn't have a base note to refer to.
Log.d(TAG, "Notify Zapped note not available")
val user = senderInfo.first.toBestDisplayName() val user = senderInfo.first.toBestDisplayName()
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount) var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" } senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
val content = var content =
stringRes( stringRes(
applicationContext, applicationContext,
R.string.app_notification_zaps_channel_message_from, R.string.app_notification_zaps_channel_message_from,
user, user,
) )
zappedContent.let {
content +=
" " +
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_for,
zappedContent,
)
}
val userPicture = senderInfo.first.profilePicture() val userPicture = senderInfo.first.profilePicture()
val noteUri = val noteUri =
"notifications$ACCOUNT_QUERY_PARAM" + "notifications$ACCOUNT_QUERY_PARAM" +
@@ -585,7 +592,7 @@ class EventNotificationConsumer(
.toNpub() + .toNpub() +
SCROLL_TO_QUERY_PARAM + event.id SCROLL_TO_QUERY_PARAM + event.id
Log.d(TAG) { "Notify ${event.id} $title $noteUri" } Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
notificationManager() notificationManager()
.sendZapNotification( .sendZapNotification(
@@ -598,6 +605,41 @@ class EventNotificationConsumer(
applicationContext, applicationContext,
) )
} }
} else {
// doesn't have a base note to refer to.
Log.d(TAG, "Notify Zapped note not available")
val user = senderInfo.first.toBestDisplayName()
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
val content =
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_from,
user,
)
val userPicture = senderInfo.first.profilePicture()
val noteUri =
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
.toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
notificationManager()
.sendZapNotification(
event.id,
content,
title,
event.createdAt,
userPicture,
noteUri,
applicationContext,
)
} }
} }
} }
@@ -609,14 +651,9 @@ class EventNotificationConsumer(
) { ) {
Log.d(TAG, "New Reaction to Notify") Log.d(TAG, "New Reaction to Notify")
// old event being re-broadcast // Age + self-author gates run centrally in dispatchForAccount.
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return // p-tag match already enforced by consumeFromCache; no redundant
// isTaggedUser re-check needed.
// 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 reactedPostId = event.originalPost().firstOrNull() ?: return
val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId) val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId)
@@ -676,38 +713,180 @@ class EventNotificationConsumer(
) )
} }
private suspend fun notify(
event: TextNoteEvent,
account: Account,
) {
Log.d(TAG, "New TextNote to Notify")
// Age + self-author gates run centrally in dispatchForAccount.
val replyTargetId = event.replyingTo()
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
}
}
// Not a reply to us but we're p-tagged — a mention or citation.
notifyMention(event, account)
}
private suspend fun notify(
event: CommentEvent,
account: Account,
) {
Log.d(TAG, "New NIP-22 Comment to Notify")
// Age + self-author gates run centrally in dispatchForAccount.
// 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 }
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
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(
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: Event,
account: Account,
) {
// Age + self-author gates run centrally in dispatchForAccount.
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,
)
}
private suspend fun notifyChessEvent( private suspend fun notifyChessEvent(
event: BaseChessEvent, event: BaseChessEvent,
account: Account, account: Account,
contentStringRes: Int, contentStringRes: Int,
) { ) {
if ( // Age + self-author gates run centrally in dispatchForAccount.
event.createdAt > TimeUtils.fifteenMinutesAgo() && val author = LocalCache.getOrCreateUser(event.pubKey)
event.pubKey != account.signer.pubKey val user = author.toBestDisplayName()
) { val userPicture = author.profilePicture()
val author = LocalCache.getOrCreateUser(event.pubKey) val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name)
val user = author.toBestDisplayName() val content = stringRes(applicationContext, contentStringRes, user)
val userPicture = author.profilePicture() val noteUri =
val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name) "notifications$ACCOUNT_QUERY_PARAM" +
val content = stringRes(applicationContext, contentStringRes, user) account.signer.pubKey
val noteUri = .hexToByteArray()
"notifications$ACCOUNT_QUERY_PARAM" + .toNpub() +
account.signer.pubKey SCROLL_TO_QUERY_PARAM + event.id
.hexToByteArray()
.toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
notificationManager() notificationManager()
.sendChessNotification( .sendChessNotification(
event.id, event.id,
content, content,
title, title,
event.createdAt, event.createdAt,
userPicture, userPicture,
noteUri, noteUri,
applicationContext, applicationContext,
) )
}
} }
private suspend fun notifyIncomingCall( private suspend fun notifyIncomingCall(
@@ -21,24 +21,45 @@
package com.vitorpamplona.amethyst.service.notifications package com.vitorpamplona.amethyst.service.notifications
import android.content.Context import android.content.Context
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent 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.core.toHexKey
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.bech32.bechToBytes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
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
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -69,12 +90,27 @@ class NotificationDispatcher(
// consumeFromCache can't route it. It's delivered directly via // consumeFromCache can't route it. It's delivered directly via
// [notifyWelcome] from processMarmotWelcomeFlow, which does know the // [notifyWelcome] from processMarmotWelcomeFlow, which does know the
// recipient account. // recipient account.
private val NOTIFICATION_KINDS = private val NOTIFICATION_KINDS: Set<Int> =
listOf( setOf(
// Direct-arrival // Direct-arrival
PrivateDmEvent.KIND, PrivateDmEvent.KIND,
LnZapEvent.KIND, LnZapEvent.KIND,
ReactionEvent.KIND, ReactionEvent.KIND,
TextNoteEvent.KIND,
CommentEvent.KIND,
// Public content kinds — routed to the Mentions channel when p-tagged.
PictureEvent.KIND,
VideoNormalEvent.KIND,
VideoShortEvent.KIND,
VideoHorizontalEvent.KIND,
VideoVerticalEvent.KIND,
ChannelMessageEvent.KIND,
PollEvent.KIND,
GitPatchEvent.KIND,
GitIssueEvent.KIND,
HighlightEvent.KIND,
LongTextNoteEvent.KIND,
WikiNoteEvent.KIND,
LiveChessGameAcceptEvent.KIND, LiveChessGameAcceptEvent.KIND,
LiveChessMoveEvent.KIND, LiveChessMoveEvent.KIND,
WakeUpEvent.KIND, WakeUpEvent.KIND,
@@ -92,21 +128,78 @@ class NotificationDispatcher(
fun start() { fun start() {
if (job?.isActive == true) return if (job?.isActive == true) return
Log.d(TAG, "Starting notification dispatcher") Log.d(TAG, "Starting notification dispatcher")
// Only fire on events created after the dispatcher starts — equivalent
// to the relay protocol's `limit: 0` subscription semantics, so we
// don't retrigger on historical re-broadcasts of events the user has
// already seen. Captured once and shared across filter rebuilds
// triggered by account changes.
val dispatcherSince = TimeUtils.now()
job = job =
scope.launch { scope.launch {
LocalCache // Ensure the saved-accounts StateFlow is primed from disk.
.observeNewEvents<Event>(Filter(kinds = NOTIFICATION_KINDS)) // accountsFlow() exposes the backing MutableStateFlow which
.collect { event -> // starts as null and only populates on the first suspend read.
try { LocalPreferences.allSavedAccounts()
consumer.consumeFromCache(event)
} catch (e: Exception) { LocalPreferences
if (e is CancellationException) throw e .accountsFlow()
Log.e(TAG, "Failed to dispatch notification for ${event.kind} ${event.id}", e) .filterNotNull()
.map { accounts ->
accounts
.filter { it.hasPrivKey || it.loggedInWithExternalSigner }
.mapNotNullTo(mutableSetOf()) { npubToHexOrNull(it.npub) }
}.distinctUntilChanged()
.collectLatest { pubkeys ->
if (pubkeys.isEmpty()) {
Log.d(TAG) { "No notifiable accounts; observer idle." }
return@collectLatest
} }
Log.d(TAG) { "Observing notifications for ${pubkeys.size} account(s)." }
// Single observer predicate. Each check is cheap and
// short-circuits so kind mismatch (by far the most
// common case) rejects before any allocation.
//
// - kind ∈ NOTIFICATION_KINDS — channel-relevant types
// - createdAt ≥ dispatcherSince — `limit: 0` semantics,
// drops re-broadcasts from before this session
// - createdAt ≥ fifteenMinutesAgo — rolling freshness,
// matches the downstream per-channel policy. Calls
// use a stricter 20s check in notifyIncomingCall so
// they still pass through.
// - event.notifies(pubkey) for any of our accounts —
// each kind decides which tag(s) name its recipients
// (lowercase `p` by default, plus uppercase `P` for
// NIP-22 root authors, etc.).
val predicate = { event: Event ->
event.kind in NOTIFICATION_KINDS &&
event.createdAt >= dispatcherSince &&
event.createdAt >= TimeUtils.fifteenMinutesAgo() &&
pubkeys.any { event.notifies(it) }
}
LocalCache
.observeNewEvents<Event>(predicate)
.collect { event ->
try {
consumer.consumeFromCache(event)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "Failed to dispatch notification for ${event.kind} ${event.id}", e)
}
}
} }
} }
} }
private fun npubToHexOrNull(npub: String): String? =
runCatching { npub.bechToBytes("npub").toHexKey() }
.onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } }
.getOrNull()
fun stop() { fun stop() {
job?.cancel() job?.cancel()
job = null job = null
@@ -28,8 +28,12 @@ import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences 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.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -69,32 +73,56 @@ class NotificationReplyReceiver : BroadcastReceiver() {
if (members.isEmpty()) return if (members.isEmpty()) return
val pendingResult = goAsync() runOnRelay(notificationManager, notificationId) {
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) sendReply(accountNpub, members, replyText)
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()
}
} }
} }
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) 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)
}
} }
@@ -48,23 +48,40 @@ 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 var mentionChannel: 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"
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 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 MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
const val KEY_REPLY_TEXT = "key_reply_text" const val KEY_REPLY_TEXT = "key_reply_text"
const val KEY_NOTIFICATION_ID = "key_notification_id" const val KEY_NOTIFICATION_ID = "key_notification_id"
const val KEY_ACCOUNT_NPUB = "key_account_npub" const val KEY_ACCOUNT_NPUB = "key_account_npub"
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members" 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 DM_SUMMARY_ID = 0x10000
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_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 { fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!! if (dmChannel != null) return dmChannel!!
@@ -150,6 +167,48 @@ 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!!
}
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( suspend fun NotificationManager.sendReactionNotification(
id: String, id: String,
messageBody: String, messageBody: String,
@@ -206,6 +265,76 @@ object NotificationUtils {
) )
} }
suspend fun NotificationManager.sendReplyNotification(
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
threadRootId: String,
inlineReply: InlineReplyTarget? = null,
) {
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 = replyGroupKeyFor(threadRootId),
category = NotificationCompat.CATEGORY_SOCIAL,
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( suspend fun NotificationManager.sendZapNotification(
id: String, id: String,
messageBody: String, messageBody: String,
@@ -438,6 +567,7 @@ object NotificationUtils {
summaryId: Int, summaryId: Int,
summaryText: String, summaryText: String,
applicationContext: Context, applicationContext: Context,
inlineReply: InlineReplyTarget? = null,
) { ) {
val notId = id.hashCode() val notId = id.hashCode()
@@ -484,6 +614,40 @@ object NotificationUtils {
.setAutoCancel(true) .setAutoCancel(true)
.setWhen(time * 1000) .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()) notify(notId, builder.build())
sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext) sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext)
+11
View File
@@ -876,6 +876,17 @@
<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>
<string name="app_notification_mentions_channel_id" translatable="false">MentionsID</string>
<string name="app_notification_mentions_channel_name">Mentions</string>
<string name="app_notification_mentions_channel_description">Notifies you when somebody mentions or cites you in a post</string>
<string name="app_notification_mentions_channel_message">%1$s mentioned you</string>
<string name="app_notification_mentions_summary">New mentions</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>
@@ -22,18 +22,21 @@ package com.vitorpamplona.amethyst.commons.model.observables
import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/** /**
* Emits each new event that matches the given Nostr filter, one at a time. * Emits each new event for which [predicate] returns true, one at a time,
* as it is inserted into the cache. Unlike [EventListMatchingFilter] /
* [NoteListMatchingFilter], this does not accumulate a list it simply
* calls [onNew] per matching event. Useful for reactive event-triggered
* pipelines like notifications.
* *
* Unlike [EventListMatchingFilter] / [NoteListMatchingFilter], this does not * The predicate has to be fast it runs on every new cache insertion.
* accumulate a list it simply calls [onNew] per matching event as it is * Callers with a Nostr [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter]
* inserted into the cache. Useful for reactive event-triggered pipelines * can pass `filter::match` as the predicate and compose additional checks
* (e.g. notifications) that need per-event delivery without list overhead. * with `&&`.
*/ */
class NewEventMatchingFilter<T : Event>( class NewEventMatchingFilter<T : Event>(
private val filter: Filter, private val predicate: (Event) -> Boolean,
private val onNew: (T) -> Unit, private val onNew: (T) -> Unit,
) : Observable { ) : Observable {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -41,7 +44,7 @@ class NewEventMatchingFilter<T : Event>(
event: Event, event: Event,
note: Note, note: Note,
) { ) {
if (filter.match(event)) { if (predicate(event)) {
onNew(event as T) onNew(event as T)
} }
} }
@@ -61,6 +61,23 @@ class WakeUpEvent(
fun kinds() = tags.kinds() fun kinds() = tags.kinds()
/**
* A WakeUpEvent's `p` tags point to the authors of the referenced subject
* events (see [authorKeys]), **not** to the account the wake-up should be
* delivered to. Example: Bob reacts to Alice's note, a WakeUpEvent about
* Bob's reaction p-tags Bob but it's Alice's device that needs to wake
* up to process the reaction.
*
* WakeUpEvents reach this device through transport-level routing (push,
* relay subscription). By the time one lands in [LocalCache], it is
* already "for us" so every logged-in signing account is a valid
* recipient to kick the relay wakeup on behalf of. Returning true here
* means the dispatcher invokes [com.vitorpamplona.quartz.experimental.
* notifications.wake.WakeUpEvent]-handling for each logged-in account,
* which is fine because keeping relay connections alive is idempotent.
*/
override fun notifies(userHex: HexKey): Boolean = true
companion object { companion object {
const val KIND = 23903 const val KIND = 23903
const val ALT_DESCRIPTION = "WakeUp" const val ALT_DESCRIPTION = "WakeUp"
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.core
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventKSerializer import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventKSerializer
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -45,6 +46,24 @@ open class Event(
*/ */
open fun isContentEncoded() = false open fun isContentEncoded() = false
/**
* Returns true when this event is intended to notify [userHex].
*
* The default delegates to
* [com.vitorpamplona.quartz.nip01Core.tags.people.PTag.isNotifying],
* i.e. "any lowercase `p` tag addresses the user" the convention for
* most kinds that address a single recipient or a set of mentions
* (NIP-01 mentions, NIP-04/17 DMs, NIP-25 reactions, NIP-28 chat
* messages, NIP-34 git issues/patches, NIP-57 zap receipts, NIP-68
* pictures, NIP-71 videos, NIP-84 highlights, NIP-AC calls, chess,
* wiki/long-form/poll mentions).
*
* Subclasses override when the NIP defines additional notification tags
* e.g. NIP-22 comments use uppercase `P` for the root author in
* addition to lowercase `p` for the direct-reply author.
*/
open fun notifies(userHex: HexKey): Boolean = PTag.isNotifying(tags, userHex)
fun toJson(): String = OptimizedJsonMapper.toJson(this) fun toJson(): String = OptimizedJsonMapper.toJson(this)
companion object { companion object {
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
@@ -54,6 +55,24 @@ data class PTag(
key: HexKey, key: HexKey,
): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1] == key ): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1] == key
/**
* Returns true if any `p` tag inside [tags] addresses [userHex].
* This is the canonical check for "does this event notify this user
* under the lowercase-`p` convention" and is the default used by
* [Event.notifies]. Kinds that address recipients through other tag
* names (e.g. NIP-22 uppercase `P` for root authors) override
* [Event.notifies] and may combine this with their own checks.
*/
fun isNotifying(
tags: TagArray,
userHex: HexKey,
): Boolean {
for (tag in tags) {
if (isTagged(tag, userHex)) return true
}
return false
}
fun parse(tag: Tag): PTag? { fun parse(tag: Tag): PTag? {
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null } ensure(tag[0] == TAG_NAME) { return null }
@@ -197,6 +197,15 @@ class CommentEvent(
override fun unmarkedReplyTos() = emptyList<String>() override fun unmarkedReplyTos() = emptyList<String>()
/**
* NIP-22 addresses two distinct recipients: the direct-reply author
* (lowercase `p` via [ReplyAuthorTag]) and the root-scope author
* (uppercase `P` via [RootAuthorTag]). A comment several levels deep
* only tags the root author with uppercase `P`, so the base-class
* lowercase-only default would miss them.
*/
override fun notifies(userHex: HexKey): Boolean = super.notifies(userHex) || rootAuthorKeys().contains(userHex)
override fun replyingTo(): HexKey? = override fun replyingTo(): HexKey? =
tags.lastNotNullOfOrNull(ReplyEventTag::parseKey) tags.lastNotNullOfOrNull(ReplyEventTag::parseKey)
?: tags.lastNotNullOfOrNull(RootEventTag::parseKey) ?: tags.lastNotNullOfOrNull(RootEventTag::parseKey)