Merge pull request #2538 from vitorpamplona/claude/add-reply-notifications-GvMOn
Add reply and mention notifications for public notes
This commit is contained in:
@@ -384,14 +384,20 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||
|
||||
/**
|
||||
* Emits each new event that matches the filter, one at a time, as it is
|
||||
* inserted into the cache. Unlike [observeEvents], this does not accumulate
|
||||
* a list — useful for per-event reactive pipelines like notifications.
|
||||
* Emits each new event for which [predicate] returns true, one at a time,
|
||||
* as it is inserted into the cache. Unlike [observeEvents], this does not
|
||||
* 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 {
|
||||
val newFilter =
|
||||
NewEventMatchingFilter<T>(filter) {
|
||||
NewEventMatchingFilter<T>(predicate) {
|
||||
trySend(it)
|
||||
}
|
||||
|
||||
@@ -402,6 +408,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> = observeNewEvents(filter::match)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
|
||||
|
||||
|
||||
+400
-221
@@ -37,9 +37,12 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.InlineReplyTarget
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendMentionNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReplyNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount
|
||||
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.nip01Core.core.Event
|
||||
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.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.bech32.bechToBytes
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
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.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.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
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.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -129,15 +146,15 @@ class EventNotificationConsumer(
|
||||
|
||||
if (!notificationManager().areNotificationsEnabled()) return@withWakeLock
|
||||
|
||||
val taggedNpubs =
|
||||
event
|
||||
.taggedUserIds()
|
||||
.mapTo(mutableSetOf()) { LocalCache.getOrCreateUser(it).pubkeyNpub() }
|
||||
if (taggedNpubs.isEmpty()) return@withWakeLock
|
||||
|
||||
// Ask the event which of our signing accounts it's notifying.
|
||||
// Each kind declares its own notification semantics in
|
||||
// [Event.notifies] (lowercase `p` by default, NIP-22 also checks
|
||||
// uppercase `P`, etc.), so we don't hard-code tag names here.
|
||||
LocalPreferences.allSavedAccounts().forEach { savedAccount ->
|
||||
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
|
||||
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(
|
||||
event: Event,
|
||||
account: Account,
|
||||
) {
|
||||
// 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) {
|
||||
is CallOfferEvent -> {
|
||||
notifyIncomingCall(event, account)
|
||||
@@ -170,13 +195,45 @@ class EventNotificationConsumer(
|
||||
// Everything else is suppressed while the user is actively on the home screen.
|
||||
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) {
|
||||
is PrivateDmEvent -> notify(event, account)
|
||||
|
||||
is LnZapEvent -> notify(event, account)
|
||||
|
||||
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 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 LiveChessMoveEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_your_turn)
|
||||
// WelcomeEvent is dispatched directly from processMarmotWelcomeFlow
|
||||
// (no `p` tag, so tag-based matching doesn't work).
|
||||
@@ -257,50 +314,42 @@ class EventNotificationConsumer(
|
||||
account: Account,
|
||||
) {
|
||||
Log.d(TAG, "New ChatMessage File to Notify")
|
||||
if (
|
||||
// old event being re-broadcasted
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
||||
// don't display if it comes from me.
|
||||
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)
|
||||
// Age + self-author gates run centrally in dispatchForAccount.
|
||||
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) {
|
||||
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
|
||||
if (!isKnownRoom) return
|
||||
|
||||
notificationManager()
|
||||
.sendDMNotification(
|
||||
event.id,
|
||||
content,
|
||||
user,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
accountNpub = accountNpub,
|
||||
accountPictureUrl = account.userProfile().profilePicture(),
|
||||
chatroomMembers = chatroomMembers,
|
||||
)
|
||||
}
|
||||
}
|
||||
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()
|
||||
.sendDMNotification(
|
||||
event.id,
|
||||
content,
|
||||
user,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
accountNpub = accountNpub,
|
||||
accountPictureUrl = account.userProfile().profilePicture(),
|
||||
chatroomMembers = chatroomMembers,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun notify(
|
||||
@@ -308,47 +357,42 @@ class EventNotificationConsumer(
|
||||
account: Account,
|
||||
) {
|
||||
Log.d(TAG, "New ChatMessage to Notify")
|
||||
if (
|
||||
// old event being re-broadcasted
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
||||
// don't display if it comes from me.
|
||||
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)
|
||||
// Age + self-author gates run centrally in dispatchForAccount.
|
||||
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) {
|
||||
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
|
||||
if (!isKnownRoom) return
|
||||
|
||||
notificationManager()
|
||||
.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
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()
|
||||
.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(
|
||||
@@ -356,47 +400,48 @@ class EventNotificationConsumer(
|
||||
account: Account,
|
||||
) {
|
||||
Log.d(TAG, "New Nip-04 DM to Notify")
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
// Age + self-author gates run centrally in dispatchForAccount. The
|
||||
// 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 chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
|
||||
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
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) {
|
||||
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
|
||||
if (!isKnownRoom) return
|
||||
|
||||
notificationManager()
|
||||
.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val author = note.author ?: return
|
||||
val content = decryptContent(note, account.signer) ?: return
|
||||
val user = author.toBestDisplayName()
|
||||
val userPicture = author.profilePicture()
|
||||
val accountNpub =
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
||||
|
||||
notificationManager()
|
||||
.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()}" }
|
||||
LocalCache.getNoteIfExists(event.id) ?: return
|
||||
|
||||
Log.d(TAG, "Notify Not Notified Yet")
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
|
||||
Log.d(TAG, "Notify Not an old event")
|
||||
|
||||
// Age + self-author gates run centrally in dispatchForAccount. For zaps
|
||||
// the self-check is effectively a no-op (receipts are signed by the LN
|
||||
// service, not the zapper) but the uniform rule is cheap and keeps the
|
||||
// downstream invariants simple.
|
||||
val noteZapRequest = event.zapRequest?.id?.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")
|
||||
|
||||
if (event.isTaggedUser(account.signer.pubKey)) {
|
||||
val amount = showAmount(event.amount)
|
||||
// Zap routing (recipient == account) is enforced by the dispatcher
|
||||
// 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 ->
|
||||
decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent ->
|
||||
Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" }
|
||||
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
|
||||
decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent ->
|
||||
Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" }
|
||||
|
||||
val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey)
|
||||
val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null })
|
||||
val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey)
|
||||
val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null })
|
||||
|
||||
if (noteZapped.event?.content != null) {
|
||||
decryptContent(noteZapped, account.signer)?.let { decrypted ->
|
||||
Log.d(TAG, "Notify Decrypted if Private Note")
|
||||
if (noteZapped.event?.content != null) {
|
||||
decryptContent(noteZapped, account.signer)?.let { decrypted ->
|
||||
Log.d(TAG, "Notify Decrypted if Private Note")
|
||||
|
||||
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 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)" }
|
||||
|
||||
val content =
|
||||
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" +
|
||||
@@ -585,7 +592,7 @@ class EventNotificationConsumer(
|
||||
.toNpub() +
|
||||
SCROLL_TO_QUERY_PARAM + event.id
|
||||
|
||||
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
|
||||
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
@@ -598,6 +605,41 @@ class EventNotificationConsumer(
|
||||
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")
|
||||
|
||||
// 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
|
||||
// Age + self-author gates run centrally in dispatchForAccount.
|
||||
// p-tag match already enforced by consumeFromCache; no redundant
|
||||
// isTaggedUser re-check needed.
|
||||
|
||||
val reactedPostId = event.originalPost().firstOrNull() ?: return
|
||||
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(
|
||||
event: BaseChessEvent,
|
||||
account: Account,
|
||||
contentStringRes: Int,
|
||||
) {
|
||||
if (
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
||||
event.pubKey != account.signer.pubKey
|
||||
) {
|
||||
val author = LocalCache.getOrCreateUser(event.pubKey)
|
||||
val user = author.toBestDisplayName()
|
||||
val userPicture = author.profilePicture()
|
||||
val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name)
|
||||
val content = stringRes(applicationContext, contentStringRes, user)
|
||||
val noteUri =
|
||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub() +
|
||||
SCROLL_TO_QUERY_PARAM + event.id
|
||||
// Age + self-author gates run centrally in dispatchForAccount.
|
||||
val author = LocalCache.getOrCreateUser(event.pubKey)
|
||||
val user = author.toBestDisplayName()
|
||||
val userPicture = author.profilePicture()
|
||||
val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name)
|
||||
val content = stringRes(applicationContext, contentStringRes, user)
|
||||
val noteUri =
|
||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub() +
|
||||
SCROLL_TO_QUERY_PARAM + event.id
|
||||
|
||||
notificationManager()
|
||||
.sendChessNotification(
|
||||
event.id,
|
||||
content,
|
||||
title,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
notificationManager()
|
||||
.sendChessNotification(
|
||||
event.id,
|
||||
content,
|
||||
title,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun notifyIncomingCall(
|
||||
|
||||
+104
-11
@@ -21,24 +21,45 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
|
||||
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.nip01Core.core.toHexKey
|
||||
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.bech32.bechToBytes
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
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.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
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.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -69,12 +90,27 @@ class NotificationDispatcher(
|
||||
// consumeFromCache can't route it. It's delivered directly via
|
||||
// [notifyWelcome] from processMarmotWelcomeFlow, which does know the
|
||||
// recipient account.
|
||||
private val NOTIFICATION_KINDS =
|
||||
listOf(
|
||||
private val NOTIFICATION_KINDS: Set<Int> =
|
||||
setOf(
|
||||
// Direct-arrival
|
||||
PrivateDmEvent.KIND,
|
||||
LnZapEvent.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,
|
||||
LiveChessMoveEvent.KIND,
|
||||
WakeUpEvent.KIND,
|
||||
@@ -92,21 +128,78 @@ class NotificationDispatcher(
|
||||
fun start() {
|
||||
if (job?.isActive == true) return
|
||||
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 =
|
||||
scope.launch {
|
||||
LocalCache
|
||||
.observeNewEvents<Event>(Filter(kinds = NOTIFICATION_KINDS))
|
||||
.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)
|
||||
// Ensure the saved-accounts StateFlow is primed from disk.
|
||||
// accountsFlow() exposes the backing MutableStateFlow which
|
||||
// starts as null and only populates on the first suspend read.
|
||||
LocalPreferences.allSavedAccounts()
|
||||
|
||||
LocalPreferences
|
||||
.accountsFlow()
|
||||
.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() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
|
||||
+90
-24
@@ -28,8 +28,12 @@ import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -69,32 +73,56 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
|
||||
if (members.isEmpty()) return
|
||||
|
||||
val pendingResult = goAsync()
|
||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
scope.launch {
|
||||
// activates the relay to send the message.
|
||||
val collectionJob =
|
||||
scope.launch {
|
||||
Amethyst.instance.relayProxyClientConnector.relayServices
|
||||
.collect()
|
||||
}
|
||||
|
||||
try {
|
||||
sendReply(accountNpub, members, replyText)
|
||||
notificationManager.cancel(notificationId)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
|
||||
// closes the relay connection.
|
||||
collectionJob.cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
runOnRelay(notificationManager, notificationId) {
|
||||
sendReply(accountNpub, members, replyText)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationUtils.PUBLIC_REPLY_ACTION -> {
|
||||
val replyText =
|
||||
RemoteInput
|
||||
.getResultsFromIntent(intent)
|
||||
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
|
||||
?.toString()
|
||||
|
||||
if (replyText.isNullOrBlank()) return
|
||||
|
||||
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
|
||||
val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return
|
||||
|
||||
runOnRelay(notificationManager, notificationId) {
|
||||
sendPublicReply(accountNpub, targetEventId, replyText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runOnRelay(
|
||||
notificationManager: NotificationManager,
|
||||
notificationId: Int,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
val pendingResult = goAsync()
|
||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
scope.launch {
|
||||
val collectionJob =
|
||||
scope.launch {
|
||||
Amethyst.instance.relayProxyClientConnector.relayServices
|
||||
.collect()
|
||||
}
|
||||
|
||||
try {
|
||||
block()
|
||||
notificationManager.cancel(notificationId)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
collectionJob.cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,4 +139,42 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
|
||||
account.sendNip17PrivateMessage(template)
|
||||
}
|
||||
|
||||
private suspend fun sendPublicReply(
|
||||
accountNpub: String,
|
||||
targetEventId: String,
|
||||
replyText: String,
|
||||
) {
|
||||
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return
|
||||
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
|
||||
|
||||
val targetEvent = LocalCache.getNoteIfExists(targetEventId)?.event ?: return
|
||||
|
||||
val template =
|
||||
when (targetEvent) {
|
||||
is TextNoteEvent -> {
|
||||
TextNoteEvent.build(
|
||||
note = replyText,
|
||||
replyingTo = EventHintBundle(targetEvent),
|
||||
)
|
||||
}
|
||||
|
||||
is CommentEvent -> {
|
||||
CommentEvent.replyBuilder(
|
||||
msg = replyText,
|
||||
replyingTo = EventHintBundle(targetEvent),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Non-threaded events (e.g. long-form articles) use NIP-22 comments.
|
||||
CommentEvent.replyBuilder(
|
||||
msg = replyText,
|
||||
replyingTo = EventHintBundle(targetEvent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
account.signAndComputeBroadcast(template)
|
||||
}
|
||||
}
|
||||
|
||||
+164
@@ -48,23 +48,40 @@ object NotificationUtils {
|
||||
private var zapChannel: NotificationChannel? = null
|
||||
private var reactionChannel: NotificationChannel? = null
|
||||
private var chessChannel: NotificationChannel? = null
|
||||
private var replyChannel: NotificationChannel? = null
|
||||
private var mentionChannel: NotificationChannel? = null
|
||||
|
||||
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
||||
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
||||
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
|
||||
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
|
||||
const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION"
|
||||
private const val MENTION_GROUP_KEY = "com.vitorpamplona.amethyst.MENTION_NOTIFICATION"
|
||||
|
||||
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
|
||||
const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION"
|
||||
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
|
||||
const val KEY_REPLY_TEXT = "key_reply_text"
|
||||
const val KEY_NOTIFICATION_ID = "key_notification_id"
|
||||
const val KEY_ACCOUNT_NPUB = "key_account_npub"
|
||||
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
|
||||
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
|
||||
|
||||
private const val DM_SUMMARY_ID = 0x10000
|
||||
private const val ZAP_SUMMARY_ID = 0x20000
|
||||
private const val REACTION_SUMMARY_ID = 0x40000
|
||||
private const val CHESS_SUMMARY_ID = 0x30000
|
||||
private const val REPLY_SUMMARY_ID_BASE = 0x50000
|
||||
private const val MENTION_SUMMARY_ID = 0x60000
|
||||
|
||||
/**
|
||||
* Derives a stable summary notification id for a per-thread reply group.
|
||||
* Uses the thread root id hash mixed with the base id so different threads
|
||||
* don't collide with each other or with the other channel summaries.
|
||||
*/
|
||||
fun replySummaryIdFor(threadRootId: String): Int = REPLY_SUMMARY_ID_BASE xor threadRootId.hashCode()
|
||||
|
||||
fun replyGroupKeyFor(threadRootId: String): String = "$REPLY_GROUP_KEY_PREFIX:$threadRootId"
|
||||
|
||||
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
if (dmChannel != null) return dmChannel!!
|
||||
@@ -150,6 +167,48 @@ 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!!
|
||||
}
|
||||
|
||||
fun getOrCreateMentionChannel(applicationContext: Context): NotificationChannel {
|
||||
if (mentionChannel != null) return mentionChannel!!
|
||||
|
||||
mentionChannel =
|
||||
NotificationChannel(
|
||||
stringRes(applicationContext, R.string.app_notification_mentions_channel_id),
|
||||
stringRes(applicationContext, R.string.app_notification_mentions_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description =
|
||||
stringRes(applicationContext, R.string.app_notification_mentions_channel_description)
|
||||
}
|
||||
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(mentionChannel!!)
|
||||
|
||||
return mentionChannel!!
|
||||
}
|
||||
|
||||
suspend fun NotificationManager.sendReactionNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
@@ -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(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
@@ -438,6 +567,7 @@ object NotificationUtils {
|
||||
summaryId: Int,
|
||||
summaryText: String,
|
||||
applicationContext: Context,
|
||||
inlineReply: InlineReplyTarget? = null,
|
||||
) {
|
||||
val notId = id.hashCode()
|
||||
|
||||
@@ -484,6 +614,40 @@ object NotificationUtils {
|
||||
.setAutoCancel(true)
|
||||
.setWhen(time * 1000)
|
||||
|
||||
if (inlineReply != null) {
|
||||
val remoteInput =
|
||||
RemoteInput
|
||||
.Builder(KEY_REPLY_TEXT)
|
||||
.setLabel(stringRes(applicationContext, R.string.app_notification_reply_label))
|
||||
.build()
|
||||
|
||||
val replyIntent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
action = PUBLIC_REPLY_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_ACCOUNT_NPUB, inlineReply.accountNpub)
|
||||
putExtra(KEY_TARGET_EVENT_ID, inlineReply.targetEventId)
|
||||
}
|
||||
|
||||
val replyPendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
notId,
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val replyAction =
|
||||
NotificationCompat.Action
|
||||
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.build()
|
||||
|
||||
builder.addAction(replyAction)
|
||||
}
|
||||
|
||||
notify(notId, builder.build())
|
||||
|
||||
sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext)
|
||||
|
||||
@@ -876,6 +876,17 @@
|
||||
<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_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 -->
|
||||
<string name="app_notification_calls_channel_name">Incoming calls</string>
|
||||
|
||||
Reference in New Issue
Block a user