diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 06f0066bc..9cd398148 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -395,8 +395,17 @@ class AppModules( ) } - // Manages always-on notification service lifecycle - val alwaysOnNotificationServiceManager = AlwaysOnNotificationServiceManager(appContext, applicationIOScope) + // Manages always-on notification service lifecycle. Preloads every saved + // writable account while enabled so GiftWraps for non-active accounts still + // get unwrapped by their owning account's newNotesPreProcessor. + val alwaysOnNotificationServiceManager = + AlwaysOnNotificationServiceManager( + context = appContext, + scope = applicationIOScope, + accountsCache = accountsCache, + localPreferences = LocalPreferences, + activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey }, + ) // Observes LocalCache for notification-relevant events and routes them to // EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index cbabea8dc..8858af67c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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 observeNewEvents(filter: Filter): Flow = + fun observeNewEvents(predicate: (Event) -> Boolean): Flow = callbackFlow { val newFilter = - NewEventMatchingFilter(filter) { + NewEventMatchingFilter(predicate) { trySend(it) } @@ -402,6 +408,8 @@ object LocalCache : ILocalCache, ICacheProvider { } } + fun observeNewEvents(filter: Filter): Flow = observeNewEvents(filter::match) + @Suppress("UNCHECKED_CAST") fun observeLatestEvent(filter: Filter) = observeEvents(filter).map { it.firstOrNull() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index cbf023d8c..bec0e724d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model.accountsCache import android.content.ContentResolver +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache @@ -68,6 +69,42 @@ class AccountCacheState( } } + /** + * Loads every saved account that can sign (has a private key or an external signer) + * into the cache. Safe to call repeatedly — [loadAccount] is idempotent, so already + * loaded accounts are returned as-is. Used by the always-on notification service so + * GiftWraps addressed to non-active accounts still get unwrapped and notified. + */ + suspend fun loadAllWritableAccounts(localPreferences: LocalPreferences) { + localPreferences.allSavedAccounts().forEach { savedAccount -> + if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach + try { + val accountSettings = localPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach + loadAccount(accountSettings) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.w("AccountCacheState", "Failed to preload account ${savedAccount.npub}: ${e.message}", e) + } + } + } + + /** + * Cancels and removes every cached account whose pubkey is not in [keepPubkeys]. + * Used to release accounts that were preloaded for background notification handling + * when the always-on service is turned off, while preserving the active account. + */ + fun retainOnly(keepPubkeys: Set) { + accounts.update { existingAccounts -> + val toRemove = existingAccounts.filterKeys { it !in keepPubkeys } + if (toRemove.isEmpty()) { + existingAccounts + } else { + toRemove.values.forEach { it.scope.cancel() } + existingAccounts.minus(toRemove.keys) + } + } + } + fun loadAccount(accountSettings: AccountSettings): Account = loadAccount( signer = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index 37fc34447..b124ba8ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -21,8 +21,12 @@ 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.accountsCache.AccountCacheState +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest @@ -40,16 +44,27 @@ import kotlinx.coroutines.launch * When enabled, all layers activate. When disabled, all layers deactivate. * The manager watches the account's alwaysOnNotificationService setting * and reacts to changes in real time. + * + * While enabled, every saved writable account is kept loaded in + * [AccountCacheState] so GiftWraps addressed to any of them (delivered via + * open relay subscriptions) get unwrapped by the owning account's + * `newNotesPreProcessor`. Without this, wraps for non-active accounts would + * sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber + * able to decrypt them. */ class AlwaysOnNotificationServiceManager( private val context: Context, private val scope: CoroutineScope, + private val accountsCache: AccountCacheState, + private val localPreferences: LocalPreferences, + private val activePubKeyProvider: () -> HexKey?, ) { companion object { private const val TAG = "AlwaysOnNotifManager" } private var watchJob: Job? = null + private var preloadJob: Job? = null private var wasEnabled = false /** @@ -76,6 +91,8 @@ class AlwaysOnNotificationServiceManager( fun stop() { watchJob?.cancel() watchJob = null + preloadJob?.cancel() + preloadJob = null } private fun enableAllLayers() { @@ -91,6 +108,8 @@ class AlwaysOnNotificationServiceManager( ServiceWatchdogManager.schedule(context) // L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest + + startMultiAccountPreload() } private fun disableAllLayers() { @@ -104,5 +123,47 @@ class AlwaysOnNotificationServiceManager( // L5: Cancel watchdog alarm ServiceWatchdogManager.cancel(context) + + stopMultiAccountPreload() + } + + /** + * Preloads every saved writable account into [AccountCacheState] and keeps the set + * in sync by observing [LocalPreferences.accountsFlow]. New accounts added while + * the service is enabled (login flow) are picked up automatically. + * + * Note: the first [LocalPreferences.accountsFlow] emission is `null` (lazily + * populated). We still call [AccountCacheState.loadAllWritableAccounts] on every + * emission — its suspend call to `allSavedAccounts()` triggers flow population, + * and subsequent [loadAccount] calls are idempotent on already-loaded accounts. + */ + private fun startMultiAccountPreload() { + preloadJob?.cancel() + preloadJob = + scope.launch { + localPreferences.accountsFlow().collect { + try { + accountsCache.loadAllWritableAccounts(localPreferences) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Multi-account preload failed: ${e.message}", e) + } + } + } + } + + /** + * Cancels the preload collector and releases every cached account except the + * currently active one, so users with the setting off return to single-account + * memory/battery footprint. + */ + private fun stopMultiAccountPreload() { + preloadJob?.cancel() + preloadJob = null + // remove this because we don't know which other accounts might be getting used. + // val active = activePubKeyProvider() + // if (active != null) { + // accountsCache.retainOnly(setOf(active)) + // } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 08aa2a579..7470825af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index f4ff5f82d..8ca88478a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -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 = + 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(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(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index c51877b63..f8d8923af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -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) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index a3aeefddb..7ff9d453f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -48,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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt index c5d7e1564..c521a2229 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt @@ -51,7 +51,6 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.placeholderText -import java.util.Locale import kotlin.math.round @Composable @@ -103,7 +102,7 @@ fun ForwardZapTo( Column(modifier = Modifier.weight(1f)) { UsernameDisplay(splitItem.key, accountViewModel = accountViewModel) Text( - text = String.format(Locale.getDefault(), "%.0f%%", splitItem.percentage * 100), + text = "${(splitItem.percentage * 100).toInt()}%", maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Bold, diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 3f62f9362..9124bd2f1 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -49,6 +49,8 @@ Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy meg tudja tolni a bejegyzéseket Ön nyilvános kulcsot használ, és a nyilvános kulcsok csak olvashatóak. Jelentkezzen be a privát kulccsal a hozzászólások kedveléséhez Nincs beállítva Zap-összeg. Koppintson hosszan a beállításhoz + Névtelen + létrehozott egy klippet Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatók a bejegyzések. Jelentkezzen be a privát kulcsával, hogy Zap-et tudjon küldeni Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy követni tudjon embereket Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy ki tudja követni az embereket, akiket követ @@ -271,6 +273,7 @@ rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek Nyilvános csevegés MLS-csoport + Még nincs üzenet Nyilvános csevegés metaadatai A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki részt vehet bennük. Ezek kiválóan alkalmasak bizonyos témák köré szerveződő nyílt közösségek számára. @@ -335,6 +338,11 @@ "<Nem sikerült a privát üzenetet visszafejteni>\n\nÖnt megemlítették egy privát/titkosított beszélgetésben %1$s és %2$s között." Új fiók hozzáadása Fiókok + Navigálás + Ön + Hírfolyamok + Létrehozás + Rendszer Fiók kiválasztása Új fiók hozzáadása Aktív fiók @@ -390,6 +398,26 @@ Megnyitás Lezárva Kitűzők + Közösségek + Új közösség + Közösség szerkesztése + Közösség neve + Leírás + Szabályok (nem kötelező) + Borítókép hozzáadása + Koppintson egy képre a kiválasztáshoz – a kép fel lesz töltve a médiskiszolgálóra. + Moderátorok + A moderátorok jóváhagyhatják a bejegyzéseket. Ön mindig moderátor. + Egy moderátor hozzáadása + Név, npub vagy NIP-05 + Tulajdonos + Átjátszók + Válasszon átjátzsókat, amelyek a kéréseket, jóváhagyásokat vagy a közösségi szerzők metaadatait fogják tárolni. + Bármelyik + Szerző + Kérések + Jóváhagyások + Nincs olyan közösség, amely megfelelne ennek a szűrőnek. Fogadott Saját Kitüntetett @@ -425,6 +453,11 @@ Ön még nem kapott kitűzőt. Képek Rövidek + Nyilvános csevegések + Követési csomagok + Élő közvetítések + Hangszobák + Közönség Videók Cikkek Privát könyvjelzők @@ -452,6 +485,20 @@ Hivakozások megtekintése Kulcsszavak megtekintése Még nincs egyetlen könyvjelzőlistája sem. Koppintson az „Új” gombra, hogy létrehozzon egyet. + Kulcsszó-csomagok + Még nincs egyetlen érdeklődési csomagja sem. Koppintson az „Új” gombra, hogy létrehozzon egyet. + Új érdeklődési csomag + Új érdeklődési csomag + Név megadása + Saját érdeklődések + Kulcsszó hozzáadása + Privát + %1$d kucsszó + Új érdeklődési csomagműveletek + Átnevezés + Klónozás + Kulcsszó hozzáadása + Váltás nyilvános vagy privát között Privát bejegyzések Privát bejegyzések (%1$s) Nyilvános bejegyzések @@ -771,6 +818,16 @@ Kiszolgáló webcíme Felhasználónév Hitelesítési adatok + Aktív kapcsolatot tart fenn a beérkező üzenetek átjátszóival a valós idejű értesítések érdekében + Amethyst értesítések aktíválva + Kapcsolódva %1$d beérkező üzenetátjátszóhoz + Kapcsolódás a beérkező üzenetátjátszókhoz\u2026 + Szüneteltetés + Folyamatos értesítési szolgáltatás + Folyamatos kapcsolatot tart fenn a beérkező üzenetek átjátszóival az értesítések azonnali kézbesítése érdekében. Megjeleníti a folyamatban lévő értesítéseket. Több akkumulátort fogyaszt, de így biztosan nem marad le egyetlen üzenetről sem. + Akkumulátor-optimalizálás aktív + Az Android korlátozhatja a háttérben futó átjátszókapcsolatokat. Az Amethyst esetében tiltsa le az akkumulátor-optimalizálást a megbízható értesítések biztosítása érdekében. + Javítás most Értesítés: Csatlakozás a beszélgetéshez Felhasználó- vagy csoport-azonosító @@ -936,6 +993,7 @@ Fiókbeállítások Alkalmazásbeállítások Kritikus beállítások + Visszaállítás Mindig Csak Wi-Fi-n Korlátlan Wi-Fi @@ -954,6 +1012,7 @@ Téma Képelőnézet Videólejátszás + Videók automatikus lejátszása Webcím-előnézet Magával ragadó görgetés Navigációs sáv elrejtése görgetéskor @@ -1023,7 +1082,8 @@ Az alkalmazás felületéhez Sötét, világos vagy a rendszer által használt téma Képek és GIF-ek automatikus betöltése - Videók és GIF-ek automatikus lejátszása + Videók és GIF-ek automatikus betöltése + Videók automatikus lejátszása, amint megjelennek a kijelzőn Webcím-előnézetek megjelenítése Mikor töltse be a képeket Köteg másolása @@ -1305,6 +1365,10 @@ Tetszik Zap Gyors reakciók megváltoztatása + Alsó navigációs sáv + Húzással rendezheti át az elemek sorrendjét. A kapcsolóval elemeket adhat hozzá az alsó sávhoz, illetve eltávolíthat onnan. Ha nincs elem, az alsó sáv el van rejtve. + Elérhető + Újrarendezés Reakciósor Állítsa be, hogy mely reakciógombok jelenjenek meg, azok sorrendjét, valamint a számlálók megjelenítését. Engedélyezve @@ -1322,6 +1386,23 @@ Ennek a bejegyzésnek a megosztása külső alkalmazásban Fizetés Fizetés küldése a szerzőnek az elérendő fizetési céljainak segítségével + Videólejátszó gombjai + Válassza ki, mely gombok jelenjenek meg a videolejátszón, és melyek a kiegészítő menüben. Húzással rendezheti át a sorrendet. + Újrarendezés + Felső sáv + Túlcsorduló menü + Teljes képernyő + Videó megnyitása teljes képernyős nézetben + Némítás + Hang ki/be + Videóminőség + Válasszon felbontást a HLS-videókhoz (rejtett a nem HLS-videók esetében) + Megosztás vagy mentés + Ossza meg a videó hivatkozását külső oldalakon + Mentés a telefonra + Töltse le a videót a készülékedre (rejtett az élő közvetítéseknél) + Kép a képben + Videó lejátszása lebegő ablakban (rejtett, ha a rendszer nem támogatja) %1$s profilképe %1$s átjátszó Átjátszólista kibontása @@ -1525,6 +1606,7 @@ Szempont kiválasztása a hírfolyam szűréséhez Hírfolyamok Kulcsszavak + Érdeklődési csomagok Helyszínek Közösségek Listák @@ -1553,6 +1635,21 @@ Nem sikerült megosztani a videót, próbálja meg újra később… Videó letöltése… Kulcsszó keresése: #%1$s + Összes + Emberek + Bejegyzések + Helyi + Átjátszók + Csak követettek + Legújabb + Legrégebbi + Relevancia + Népszerű + Szűrők + Visszaállítás + Forrás + Rendezés + Szűrők Innentől NE fordítsa le Az itt látható nyelvek nem lesznek lefordítva. Az eltávolításához és az újbóli fordításhoz válasszon ki egy nyelvet. Fordítás erre: @@ -2095,4 +2192,38 @@ Erőteljes + Emodzsi + Emodzsicsomagok + Hozzáadás az emodzsilistához + Hozzáadás a saját emodzsilistához + Eltávolítás a saját emodzsilistáról + Új emodzsicsomag + Emodzsicsomag szerkesztése + Rövid kód (például: :smile:) + Csak betűk, számok, kötőjelek és alsó kötőjelek + Kép webcíme + Csomag címe (nem kötelező) + Csomag neve + Leírás (nem kötelező) + Borítókép webcíme (nem kötelező) + Egy borítókép feltöltése + Válasszon ki egy négyzet alakú képet, amely ezt az emodzsicsomagot szemlélteti. + Önnek még nincsenek emodzsicsomagjai + %1$d emodzsi + Saját emodzsilista + Kiválasztott listához hozzáadott emodzsicsomagok (NIP-51 típus 10030) + Még nincsenek emodzsicsomagok a listán + Emodzsi hozzáadása + Egyéni emodzsi hozzáadása + %1$s eltávolítása:? + Érintsen meg hosszan egy emodzsit az eltávolításhoz + A(z) „%1$s” már az emodzsilistában van + A(z) „%1$s” még nincs az emodzsilistában + Emodzsicsomag-műveletek + Saját emodzsicsomagok + Emodzsik + Emodzsicsomagok böngészése + Privát + Privát emodzsi + A nyilvános emodzsik mindenki számára láthatók, és megjelennek a reakciómenüben, valamint a „:” automatikus kiegészítő listájában, ha ez a csomag szerepel az emodzsilistában. + A privát emodzsik titkosítva kerülnek tárolásra az átjátszókon, és csak Önnek láthatók. Ugyanúgy megjelennek a reakciómenüben és a „:” automatikus kiegészítésben, mint a nyilvánosak. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4bbb8f3e0..c7446c01b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -876,6 +876,17 @@ %1$s accepted your chess challenge %1$s moved — your turn Chess updates + RepliesID + Replies + Notifies you when somebody replies to your post + %1$s replied + on: %1$s + New replies + MentionsID + Mentions + Notifies you when somebody mentions or cites you in a post + %1$s mentioned you + New mentions Incoming calls diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCacheMimeTypeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCacheMimeTypeTest.kt index c2a2935ee..29e15433a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCacheMimeTypeTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCacheMimeTypeTest.kt @@ -21,12 +21,10 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem import androidx.media3.common.MimeTypes -import androidx.media3.common.util.UnstableApi import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -@OptIn(UnstableApi::class) class MediaItemCacheMimeTypeTest { @Test fun appleHlsPlaylistMimeIsNormalizedForExoPlayer() { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NewEventMatchingFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NewEventMatchingFilter.kt index 48c17196b..436f3933e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NewEventMatchingFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NewEventMatchingFilter.kt @@ -22,18 +22,21 @@ package com.vitorpamplona.amethyst.commons.model.observables import com.vitorpamplona.amethyst.commons.model.Note 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 - * accumulate a list — it simply calls [onNew] per matching event as it is - * inserted into the cache. Useful for reactive event-triggered pipelines - * (e.g. notifications) that need per-event delivery without list overhead. + * The predicate has to be fast — it runs on every new cache insertion. + * Callers with a Nostr [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter] + * can pass `filter::match` as the predicate and compose additional checks + * with `&&`. */ class NewEventMatchingFilter( - private val filter: Filter, + private val predicate: (Event) -> Boolean, private val onNew: (T) -> Unit, ) : Observable { @Suppress("UNCHECKED_CAST") @@ -41,7 +44,7 @@ class NewEventMatchingFilter( event: Event, note: Note, ) { - if (filter.match(event)) { + if (predicate(event)) { onNew(event as T) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt index 6dfb9dadc..33b567a8d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt @@ -61,6 +61,23 @@ class WakeUpEvent( 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 { const val KIND = 23903 const val ALT_DESCRIPTION = "WakeUp" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt index d5cdbee52..906773852 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventKSerializer 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.TimeUtils import kotlinx.serialization.Serializable @@ -45,6 +46,24 @@ open class Event( */ 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) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt index 8d6eaba2a..9475e0637 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.hexToByteArray import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle @@ -54,6 +55,24 @@ data class PTag( key: HexKey, ): 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? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt index efd2a404d..17b51a2ab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt @@ -197,6 +197,15 @@ class CommentEvent( override fun unmarkedReplyTos() = emptyList() + /** + * 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? = tags.lastNotNullOfOrNull(ReplyEventTag::parseKey) ?: tags.lastNotNullOfOrNull(RootEventTag::parseKey)