diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index 284846799d..de4b90e7ef 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -97,8 +97,8 @@ class PushMessageReceiver : MessagingReceiver() { Log.d(TAG) { "Building okHttpClient, useTor: ${Amethyst.instance.torManager.isSocksReady()}" } Amethyst.instance.okHttpClients.getHttpClient(Amethyst.instance.torManager.isSocksReady()) } - NotificationUtils.getOrCreateZapChannel(appContext) - NotificationUtils.getOrCreateDMChannel(appContext) + NotificationCategory.ZAP.ensureChannel(appContext) + NotificationCategory.DIRECT_MESSAGE.ensureChannel(appContext) } // } else { Log.d(TAG) { "Same endpoint provided:- ${endpoint.url} for Instance: $instance $sanitizedEndpoint" } 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 123a03e162..e82e26027a 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 @@ -20,12 +20,10 @@ */ package com.vitorpamplona.amethyst.service.notifications -import android.app.NotificationManager import android.content.Context import android.graphics.drawable.BitmapDrawable import android.os.PowerManager import android.os.SystemClock -import androidx.core.content.ContextCompat import coil3.ImageLoader import coil3.asDrawable import coil3.request.ImageRequest @@ -38,47 +36,47 @@ 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.notifications.renderers.ArticleNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.BadgeNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.ChessNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.CodeNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.DirectMessageNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.GroupMessageNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.MediaNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.MentionNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.ReactionNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.ReplyNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.RepostNotification +import com.vitorpamplona.amethyst.service.notifications.renderers.ZapNotification import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter -import com.vitorpamplona.amethyst.ui.stringRes 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.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.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent 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.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent 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.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -89,24 +87,31 @@ 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.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull -import java.math.BigDecimal import kotlin.coroutines.cancellation.CancellationException private const val TAG = "EventNotificationConsumer" -private const val ACCOUNT_QUERY_PARAM = "?account=" -private const val SCROLL_TO_QUERY_PARAM = "&scrollTo=" +/** + * Turns a notification-relevant [Event] into a tray notification. Owns only the + * *policy* — account matching, the shared suppress-when-foreground / don't-notify- + * myself / muted-thread gates, and the routing of each kind to its renderer. + * The *presentation* (title, body, style, accent, observability) lives in the + * per-kind files under `renderers/`. + */ class EventNotificationConsumer( private val applicationContext: Context, /** Reports how long each notification-processing wakelock was held (resource-usage ledger). */ @@ -144,32 +149,15 @@ class EventNotificationConsumer( /** * Entry point for notification-relevant events arriving into [LocalCache] * from any source (FCM push, UnifiedPush, Pokey, active relay subscriptions, - * NotificationRelayService). The [NotificationDispatcher] only invokes this - * after [Account.newNotesPreProcessor] has fully unwrapped wraps and seals, - * so this method receives the final inner payload directly. - * - * Matches the event to a logged-in account by its `p` tags and dispatches - * to [dispatchForAccount]. + * NotificationRelayService). Matches the event to a logged-in account by its + * `p` tags and dispatches to [dispatchForAccount]. */ suspend fun consumeFromCache(event: Event) = withWakeLock { Log.d(TAG) { "New Notification from cache: kind=${event.kind} id=${event.id}" } - if (!notificationManager().areNotificationsEnabled()) return@withWakeLock + if (!applicationContext.notificationManager().areNotificationsEnabled()) return@withWakeLock - // Match the in-app Notifications feed exactly: the event must - // p-tag the account AND pass NotificationFeedFilter.tagsAnEventByUser - // — the latter is the per-kind "is this actually for me" rule - // (reply-to-me, citation of my post, fork of my content, community - // moderation, reaction targeting my note, etc.). WakeUpEvent is the - // one exception: its [Event.notifies] is `true` unconditionally so - // every signed-in account processes it (the dispatcher bypasses - // the same way). - // - // One LocalCache lookup per event regardless of account count — - // the note is the same for every saved account. Use the Event - // overload so AddressableEvent kinds resolve to their replaceable - // note (id-keyed version has empty replyTo after insertion). val matchingNote: Note? = if (event is WakeUpEvent) { null @@ -182,9 +170,6 @@ class EventNotificationConsumer( val accountHex = npubToHexOrNull(savedAccount.npub) ?: return@forEach if (matchingNote != null) { - // Public chat replies into my messages often omit the `p` - // tag; relax the gate for them (tagsAnEventByUser keeps it - // scoped to messages actually replying to me). val taggedOrPublicChatReply = event.isTaggedUser(accountHex) || NotificationFeedFilter.isNotifiablePublicChatReply(matchingNote, accountHex) @@ -213,9 +198,6 @@ class EventNotificationConsumer( 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) @@ -231,80 +213,148 @@ 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. + // Don't push-notify events this account authored. if (event.pubKey == account.signer.pubKey) return - // Mirror NotificationFeedFilter: reactions and zaps target a note via - // `replyTo`, not via thread-root tags on the wrapper event, so checking - // the wrapper's own thread misses them. Resolve the target and drop if - // its thread is muted. (The feed extends this to reposts too, but - // RepostEvent / GenericRepostEvent aren't routed below — push doesn't - // notify on reposts at all today.) + // Drop reactions/zaps whose target note lives on a muted thread. if (event is ReactionEvent || event is LnZapEvent) { val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull() if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) return } when (event) { - is PrivateDmEvent -> notify(event, account) + is PrivateDmEvent -> DirectMessageNotification.notify(applicationContext, account, event) + is ChatMessageEvent -> DirectMessageNotification.notify(applicationContext, account, event) + is ChatMessageEncryptedFileHeaderEvent -> DirectMessageNotification.notify(applicationContext, account, event) - is LnZapEvent -> notify(event, account) + is LnZapEvent -> ZapNotification.notify(applicationContext, account, event) + is NutzapEvent -> ZapNotification.notify(applicationContext, account, event) + is OnchainZapEvent -> ZapNotification.notify(applicationContext, account, event) - is ChatMessageEvent -> notify(event, account) + is ReactionEvent -> ReactionNotification.notify(applicationContext, account, event) - is ChatMessageEncryptedFileHeaderEvent -> notify(event, account) + is RepostEvent -> RepostNotification.notify(applicationContext, account, event) + is GenericRepostEvent -> RepostNotification.notify(applicationContext, account, event) - is ReactionEvent -> notify(event, account) + is BadgeAwardEvent -> BadgeNotification.notify(applicationContext, account, event) - is TextNoteEvent -> notify(event, account) - - is CommentEvent -> notify(event, account) - - is ChannelMessageEvent -> notify(event, account) + is TextNoteEvent -> notifyTextNote(event, account) + is CommentEvent -> notifyComment(event, account) + is ChannelMessageEvent -> notifyChannelMessage(event, account) is PictureEvent, is VideoNormalEvent, is VideoShortEvent, is VideoHorizontalEvent, is VideoVerticalEvent, - is PollEvent, - is GitPatchEvent, - is GitIssueEvent, - is GitPullRequestEvent, - is GitPullRequestUpdateEvent, + -> MediaNotification.notify(applicationContext, account, event) + + is PollEvent -> MentionNotification.notify(applicationContext, account, event, titleRes = R.string.app_notification_poll_channel_message) + is HighlightEvent, is LongTextNoteEvent, is WikiNoteEvent, - -> notifyMention(event, account) + -> ArticleNotification.notify(applicationContext, account, event) - is LiveChessGameAcceptEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_challenge_accepted) + is GitIssueEvent -> CodeNotification.notify(applicationContext, account, event) + is GitPatchEvent -> CodeNotification.notify(applicationContext, account, event) + is GitPullRequestEvent -> CodeNotification.notify(applicationContext, account, event) + is GitPullRequestUpdateEvent -> CodeNotification.notify(applicationContext, account, event) - 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). + is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_challenge_accepted) + is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_your_turn) } } - suspend fun wakeUpFor( + // Reply-vs-mention decisions are source-kind-specific, so they stay in the + // dispatcher; the actual rendering is in ReplyNotification / MentionNotification. + + private suspend fun notifyTextNote( + event: TextNoteEvent, + account: Account, + ) { + 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 + ReplyNotification.notify(applicationContext, account, event, repliedNote.event?.content, threadRoot) + return + } + } + MentionNotification.notify(applicationContext, account, event) + } + + private suspend fun notifyComment( + event: CommentEvent, + account: Account, + ) { + 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 + + ReplyNotification.notify(applicationContext, account, event, parentContent, threadRoot) + } + + private suspend fun notifyChannelMessage( + event: ChannelMessageEvent, + account: Account, + ) { + val note = LocalCache.getNoteIfExists(event.id) ?: return + + if (NotificationFeedFilter.isNotifiablePublicChatReply(note, account.signer.pubKey)) { + val parentContent = + note.replyTo + ?.lastOrNull() + ?.event + ?.content + val threadRoot = event.channelId() ?: event.id + ReplyNotification.notify(applicationContext, account, event, parentContent, threadRoot) + } else { + MentionNotification.notify(applicationContext, account, event) + } + } + + // --------------------------------------------------------------------- + // Directly-dispatched Marmot events (no `p` tag → routed by the caller) + // --------------------------------------------------------------------- + + suspend fun notifyWelcome( + event: WelcomeEvent, + account: Account, + ) = withWakeLock { + GroupMessageNotification.notifyWelcome(applicationContext, account, event) + } + + suspend fun notifyGroupMessage( + innerEvent: ChatEvent, + nostrGroupId: String, + account: Account, + ) = withWakeLock { + GroupMessageNotification.notifyGroupMessage(applicationContext, account, innerEvent, nostrGroupId) + } + + // --------------------------------------------------------------------- + // Calls & wake-ups keep their bespoke, high-priority handling here. + // --------------------------------------------------------------------- + + private suspend fun wakeUpFor( event: WakeUpEvent, account: Account, ) { - // A WakeUp's whole purpose is the events it references. If it carries - // none, there's nothing to fetch — skip the 30s subscription window. val referencedTags = event.events().distinctBy { it.eventId } if (referencedTags.isEmpty()) { Log.d(TAG) { "WakeUp ${event.id} has no referenced events — skipping" } return } - // Per spec, p-tags on a WakeUp are the authors of the referenced - // events; those are whose metadata we need to render the notification. - // Fall back to e-tag author hints and finally to the WakeUp signer. val referencedNotes = referencedTags.map { LocalCache.getOrCreateNote(it.eventId) } val authorCandidates = (event.authorKeys() + referencedTags.mapNotNull { it.author }) @@ -313,7 +363,6 @@ class EventNotificationConsumer( .map { LocalCache.getOrCreateUser(it) } coroutineScope { - // keeps the relay connection active for 30 seconds. launch { try { withTimeout(WAKEUP_WINDOW_MS) { @@ -325,9 +374,6 @@ class EventNotificationConsumer( } } - // keeps subscriptions active for 30 seconds so EventFinder can pull - // the referenced events from relays and UserFinder can resolve the - // referenced authors' metadata. launch { val accountState = ScreenAuthAccount(account) val eventStates = referencedNotes.map { EventFinderQueryState(it, account) } @@ -359,717 +405,15 @@ class EventNotificationConsumer( } } - private suspend fun notify( - event: ChatMessageEncryptedFileHeaderEvent, - account: Account, - ) { - Log.d(TAG, "New ChatMessage File to Notify") - // 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 isKnownRoom = - chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || - chatroomList.hasSentMessagesTo(chatRoom) - - if (!isKnownRoom) return - - val content = chatNote.event?.content ?: "" - val user = chatNote.author?.toBestDisplayName() ?: "" - val userPicture = chatNote.author?.profilePicture() - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - val chatroomMembers = chatRoom.users.joinToString(",") - val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub - - notificationManager() - .sendDMNotification( - event.id, - content, - user, - event.createdAt, - userPicture, - noteUri, - applicationContext, - accountNpub = accountNpub, - accountPictureUrl = account.userProfile().profilePicture(), - chatroomMembers = chatroomMembers, - ) - } - - private suspend fun notify( - event: ChatMessageEvent, - account: Account, - ) { - Log.d(TAG, "New ChatMessage to Notify") - // 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 isKnownRoom = - chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || - chatroomList.hasSentMessagesTo(chatRoom) - - if (!isKnownRoom) return - - val content = chatNote.event?.content ?: "" - val user = chatNote.author?.toBestDisplayName() ?: "" - val userPicture = chatNote.author?.profilePicture() - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - val chatroomMembers = chatRoom.users.joinToString(",") - val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub - - notificationManager() - .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( - event: PrivateDmEvent, - account: Account, - ) { - Log.d(TAG, "New Nip-04 DM to Notify") - // 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 - - val note = LocalCache.getNoteIfExists(event.id) ?: return - val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey) - - val followingKeySet = account.followingKeySet() - - val chatRoom = event.chatroomKey(account.signer.pubKey) - - val isKnownRoom = - chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || - chatroomList.hasSentMessagesTo(chatRoom) - - if (!isKnownRoom) return - - 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, - ) - } - - /** - * Welcomes have no `p` tag, so [consumeFromCache]'s tag-based account match - * can't route them. They are instead dispatched here directly by - * [com.vitorpamplona.amethyst.ui.screen.loggedIn.processMarmotWelcomeFlow] - * after [MarmotManager.processWelcome] joins the group — which is also the - * only place we reliably know which account the invite was for. - */ - suspend fun notifyWelcome( - event: WelcomeEvent, - account: Account, - ) = withWakeLock { - Log.d(TAG, "New Marmot Welcome to Notify") - - if (!notificationManager().areNotificationsEnabled()) return@withWakeLock - if (MainActivity.isResumed) return@withWakeLock - - // old event being re-broadcast - if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return@withWakeLock - // a welcome we ourselves emitted - if (event.pubKey == account.signer.pubKey) return@withWakeLock - - val nostrGroupId = event.nostrGroupId() ?: return@withWakeLock - - val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) - val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: "a private group" - val inviter = LocalCache.getOrCreateUser(event.pubKey) - val inviterName = inviter.toBestDisplayName() - val inviterPicture = inviter.profilePicture() - - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - // marmot:?account= — parsed by uriToRoute below. - val noteUri = "marmot:$nostrGroupId$ACCOUNT_QUERY_PARAM$accountNpub" - - notificationManager() - .sendDMNotification( - id = event.id, - messageBody = "You've been added to $groupName", - senderName = inviterName, - time = event.createdAt, - pictureUrl = inviterPicture, - uri = noteUri, - applicationContext = applicationContext, - accountNpub = accountNpub, - accountPictureUrl = account.userProfile().profilePicture(), - chatroomMembers = null, - ) - } - - /** - * Marmot kind:445 group messages have no `p` tag (recipients are routed - * by the `h` tag carrying the nostr_group_id), so the cache-observer path - * in [NotificationDispatcher] can't match them to an account. They're - * dispatched here directly from [com.vitorpamplona.amethyst.ui.screen.loggedIn.GroupEventHandler] - * once [com.vitorpamplona.quartz.marmot.MarmotInboundProcessor] has - * decrypted the outer ChaCha20-Poly1305 layer and verified the inner - * MLS-signed payload. - * - * Typed to [ChatEvent] so the caller has to narrow first — reactions, - * control messages, and deletions stay silent at the type level, - * mirroring how NIP-17 (kind:14) is the only DM kind we notify. - */ - suspend fun notifyGroupMessage( - innerEvent: ChatEvent, - nostrGroupId: String, - account: Account, - ) = withWakeLock { - Log.d(TAG, "New Marmot Group Message to Notify") - - if (!notificationManager().areNotificationsEnabled()) return@withWakeLock - if (MainActivity.isResumed) return@withWakeLock - - // old event being re-broadcast - if (innerEvent.createdAt < TimeUtils.fifteenMinutesAgo()) return@withWakeLock - // a message we ourselves sent - if (innerEvent.pubKey == account.signer.pubKey) return@withWakeLock - - val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) - val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: "Private group" - val sender = LocalCache.getOrCreateUser(innerEvent.pubKey) - val senderName = sender.toBestDisplayName() - val senderPicture = sender.profilePicture() - // Defensive fallback for the rare empty-content ChatEvent so the - // popup is still actionable. Non-chat inner kinds were filtered - // out at the call site by the ChatEvent type narrowing. - val body = innerEvent.content.takeIf { it.isNotBlank() } ?: "New message" - - val accountNpub = - account.signer.pubKey - .hexToByteArray() - .toNpub() - // marmot:?account= — same scheme as notifyWelcome, - // taps deep-link straight to the group's chatroom. - val noteUri = "marmot:$nostrGroupId$ACCOUNT_QUERY_PARAM$accountNpub" - - notificationManager() - .sendDMNotification( - id = innerEvent.id, - messageBody = "$senderName: $body", - senderName = groupName, - time = innerEvent.createdAt, - pictureUrl = senderPicture, - uri = noteUri, - applicationContext = applicationContext, - accountNpub = accountNpub, - accountPictureUrl = account.userProfile().profilePicture(), - chatroomMembers = null, - marmotNostrGroupId = nostrGroupId, - marmotReplyToInnerEventId = innerEvent.id, - marmotReplyToInnerAuthor = innerEvent.pubKey, - ) - } - - suspend fun decryptZapContentAuthor( - event: LnZapRequestEvent, - signer: NostrSigner, - ): Event? = - if (event.isPrivateZap() && event.zappedAuthor().contains(event.pubKey)) { - signer.decryptZapEvent(event) - } else { - event - } - - suspend fun decryptContent( - note: Note, - signer: NostrSigner, - ): String? { - val event = note.event - return when (event) { - is PrivateDmEvent -> { - event.decryptContent(signer) - } - - is LnZapRequestEvent -> { - decryptZapContentAuthor(event, signer)?.content - } - - else -> { - event?.content - } - } - } - - private suspend fun notify( - event: LnZapEvent, - account: Account, - ) { - Log.d(TAG, "New Zap to Notify") - Log.d(TAG) { "Notify Start ${event.toNostrUri()}" } - LocalCache.getNoteIfExists(event.id) ?: return - - // 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 - - Log.d(TAG) { "Notify ZapRequest $noteZapRequest zapped $noteZapped" } - - // Drop zaps on muted threads, hidden authors, etc. - if (!account.isAcceptable(noteZapped)) return - - if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return - - Log.d(TAG, "Notify Amount Bigger than 10") - - // 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" } - - (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 }) - - 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 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, - ) - } - } - } - } - - private suspend fun notify( - event: ReactionEvent, - account: Account, - ) { - Log.d(TAG, "New Reaction to Notify") - - // Age + self-author gates run centrally in dispatchForAccount. - // p-tag match already enforced by consumeFromCache; no redundant - // isTaggedUser re-check needed. - - // NIP-25: when a reaction carries multiple `e` tags (e.g. both the - // thread root and the replied-to note), the LAST one is the event - // actually being reacted to. Using the first tag would surface the - // root in the tray when the like was really on a reply. This matches - // the in-app card, which resolves the target via replyTo.lastOrNull(). - val reactedPostId = event.originalPost().lastOrNull() ?: return - val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId) - - // Drop reactions on muted threads, hidden authors, etc. - if (reactedNote != null && !account.isAcceptable(reactedNote)) return - - val author = LocalCache.getOrCreateUser(event.pubKey) - val user = author.toBestDisplayName() - val userPicture = author.profilePicture() - - val reactionContent = event.content - - // NIP-30 custom emoji: content is ":shortcode:" backed by an ["emoji", shortcode, url] - // tag. The image can't be rendered as text, so it is surfaced as a badge on the author's - // avatar (see sendReactionNotification) and the title keeps just the author's name. - val customEmojiUrl = CustomEmoji.createEmojiMap(event.tags)[reactionContent] - - val title = - if (customEmojiUrl != null) { - user - } else { - val reactionSymbol = - when { - reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19" - reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E" - else -> reactionContent - } - "$reactionSymbol $user" - } - - val reactedContent = - reactedNote - ?.event - ?.content - ?.split("\n") - ?.firstOrNull() ?: "" - - val content = - if (reactedContent.isNotBlank()) { - stringRes( - applicationContext, - R.string.app_notification_reactions_channel_message_for, - reactedContent, - ) - } else { - stringRes( - applicationContext, - R.string.app_notification_reactions_channel_message, - user, - ) - } - - val noteUri = - "notifications$ACCOUNT_QUERY_PARAM" + - account.signer.pubKey - .hexToByteArray() - .toNpub() + - SCROLL_TO_QUERY_PARAM + event.id - - notificationManager() - .sendReactionNotification( - event.id, - content, - title, - event.createdAt, - userPicture, - noteUri, - applicationContext, - emojiUrl = customEmojiUrl, - ) - } - - 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 notify( - event: ChannelMessageEvent, - account: Account, - ) { - Log.d(TAG, "New Public Chat Message to Notify") - // Age + self-author gates run centrally in dispatchForAccount. - val note = LocalCache.getNoteIfExists(event.id) ?: return - - // A reply into one of my messages in this channel — even when the - // sender didn't p-tag me. Render it as a reply (threaded + inline - // reply action) grouped by channel so a busy room collapses into one - // notification instead of spamming. Falls back to a plain mention when - // I'm only p-tagged (a citation, not a reply to my message). - if (NotificationFeedFilter.isNotifiablePublicChatReply(note, account.signer.pubKey)) { - val parentContent = - note.replyTo - ?.lastOrNull() - ?.event - ?.content - val threadRoot = event.channelId() ?: event.id - notifyReply(event, account, parentContent, threadRoot) - } else { - notifyMention(event, account) - } - } - - private suspend fun notifyReply( - event: Event, - account: Account, - parentContent: String?, - threadRootId: String, - ) { - val replyNote = LocalCache.getNoteIfExists(event.id) ?: return - - // Drop events from muted threads, hidden authors, etc. - if (!account.isAcceptable(replyNote)) 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 - - // Drop events from muted threads, hidden authors, etc. - if (!account.isAcceptable(note)) 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, - ) { - // 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, - ) - } - private suspend fun notifyIncomingCall( event: CallOfferEvent, account: Account, ) { if (!account.isFollowing(event.pubKey)) return - if (TimeUtils.now() - event.createdAt > CallManager.MAX_EVENT_AGE_SECONDS) return val callerUser = LocalCache.getOrCreateUser(event.pubKey) - // If the caller's metadata hasn't been loaded yet (e.g. fresh process from - // a push notification), briefly subscribe to the user finder so we can - // resolve the user's display name instead of showing the raw pubkey. if (callerUser.metadataOrNull()?.bestName() == null) { val authorState = UserFinderQueryState(callerUser, account) try { @@ -1091,7 +435,7 @@ class EventNotificationConsumer( val callerBitmap = callerUser.profilePicture()?.let { pictureUrl -> - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + withContext(Dispatchers.IO) { try { val request = ImageRequest @@ -1113,8 +457,4 @@ class EventNotificationConsumer( applicationContext = applicationContext, ) } - - fun notificationManager(): NotificationManager = - ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) - as NotificationManager } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCategory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCategory.kt new file mode 100644 index 0000000000..a2818791d7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCategory.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.app.NotificationChannel +import android.app.NotificationChannelGroup +import android.app.NotificationManager +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Logical grouping of notification channels, shown as a section header in the + * Android system-settings page (API 26+). Lets the user silence a whole family + * (e.g. all Social notifications) with one switch, in addition to the per-kind + * channel switches. + */ +enum class NotifChannelGroup( + val id: String, + @param:StringRes val nameRes: Int, +) { + MESSAGES("com.vitorpamplona.amethyst.group.messages", R.string.app_notification_group_messages), + SOCIAL("com.vitorpamplona.amethyst.group.social", R.string.app_notification_group_social), + PAYMENTS("com.vitorpamplona.amethyst.group.payments", R.string.app_notification_group_payments), + CONTENT("com.vitorpamplona.amethyst.group.content", R.string.app_notification_group_content), + DEVELOPER("com.vitorpamplona.amethyst.group.developer", R.string.app_notification_group_developer), + GAMES("com.vitorpamplona.amethyst.group.games", R.string.app_notification_group_games), +} + +/** + * The visual + behavioral identity of one notification kind. Each entry owns: + * + * - the [NotificationChannel] it posts on (importance, name, description) — the + * unit Android lets the user silence/customize. Existing channel ids are + * reused verbatim so we never orphan a user's per-channel settings. + * - the accent [color] (`setColor`) and monochrome status-bar [smallIcon] that + * make the kind recognizable in the shade before it's read. + * - the [group] it bundles under (`setGroup`) and the [summaryId] of that + * group's summary notification. + * - the [channelGroup] it sits inside in system settings. + * - the [settingsIcon] rendered next to it in the in-app settings screen. + * + * This replaces the six ad-hoc `getOrCreate*Channel` helpers with one + * table-driven definition every renderer reads from. + */ +enum class NotificationCategory( + @param:StringRes val channelIdRes: Int, + @param:StringRes val channelNameRes: Int, + @param:StringRes val channelDescriptionRes: Int, + @param:StringRes val summaryTextRes: Int, + val importance: Int, + val color: Int, + @param:DrawableRes val smallIcon: Int, + val settingsIcon: MaterialSymbol, + val channelGroup: NotifChannelGroup, + val group: String, + val summaryId: Int, + /** Full-surface color tint — reserved for the highest-signal kinds. */ + val colorized: Boolean = false, +) { + DIRECT_MESSAGE( + channelIdRes = R.string.app_notification_dms_channel_id, + channelNameRes = R.string.app_notification_dms_channel_name, + channelDescriptionRes = R.string.app_notification_dms_channel_description, + summaryTextRes = R.string.app_notification_dms_summary, + importance = NotificationManager.IMPORTANCE_HIGH, + color = 0xFF2196F3.toInt(), // blue + smallIcon = R.drawable.ic_notif_message, + settingsIcon = MaterialSymbols.Mail, + channelGroup = NotifChannelGroup.MESSAGES, + group = "com.vitorpamplona.amethyst.DM_NOTIFICATION", + summaryId = 0x10000, + ), + REPLY( + channelIdRes = R.string.app_notification_replies_channel_id, + channelNameRes = R.string.app_notification_replies_channel_name, + channelDescriptionRes = R.string.app_notification_replies_channel_description, + summaryTextRes = R.string.app_notification_replies_summary, + importance = NotificationManager.IMPORTANCE_DEFAULT, + color = 0xFF7C4DFF.toInt(), // deep purple + smallIcon = R.drawable.ic_notif_reply, + settingsIcon = MaterialSymbols.Chat, + channelGroup = NotifChannelGroup.SOCIAL, + // Replies group per-thread; renderers override group + summaryId. This + // base is only a fallback when no thread root is known. + group = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION", + summaryId = 0x50000, + ), + MENTION( + channelIdRes = R.string.app_notification_mentions_channel_id, + channelNameRes = R.string.app_notification_mentions_channel_name, + channelDescriptionRes = R.string.app_notification_mentions_channel_description, + summaryTextRes = R.string.app_notification_mentions_summary, + importance = NotificationManager.IMPORTANCE_DEFAULT, + color = 0xFF9C27B0.toInt(), // purple + smallIcon = R.drawable.ic_notif_mention, + settingsIcon = MaterialSymbols.AlternateEmail, + channelGroup = NotifChannelGroup.SOCIAL, + group = "com.vitorpamplona.amethyst.MENTION_NOTIFICATION", + summaryId = 0x60000, + ), + REACTION( + channelIdRes = R.string.app_notification_reactions_channel_id, + channelNameRes = R.string.app_notification_reactions_channel_name, + channelDescriptionRes = R.string.app_notification_reactions_channel_description, + summaryTextRes = R.string.app_notification_reactions_summary, + importance = NotificationManager.IMPORTANCE_LOW, + color = 0xFFE91E63.toInt(), // pink / heart + smallIcon = R.drawable.ic_notif_reaction, + settingsIcon = MaterialSymbols.Favorite, + channelGroup = NotifChannelGroup.SOCIAL, + group = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION", + summaryId = 0x40000, + ), + REPOST( + channelIdRes = R.string.app_notification_reposts_channel_id, + channelNameRes = R.string.app_notification_reposts_channel_name, + channelDescriptionRes = R.string.app_notification_reposts_channel_description, + summaryTextRes = R.string.app_notification_reposts_summary, + importance = NotificationManager.IMPORTANCE_LOW, + color = 0xFF4CAF50.toInt(), // green + smallIcon = R.drawable.ic_notif_repost, + settingsIcon = MaterialSymbols.Sync, + channelGroup = NotifChannelGroup.SOCIAL, + group = "com.vitorpamplona.amethyst.REPOST_NOTIFICATION", + summaryId = 0x70000, + ), + ZAP( + channelIdRes = R.string.app_notification_zaps_channel_id, + channelNameRes = R.string.app_notification_zaps_channel_name, + channelDescriptionRes = R.string.app_notification_zaps_channel_description, + summaryTextRes = R.string.app_notification_zaps_summary, + importance = NotificationManager.IMPORTANCE_DEFAULT, + color = 0xFFF7931A.toInt(), // bitcoin orange + smallIcon = R.drawable.ic_notif_zap, + settingsIcon = MaterialSymbols.Bolt, + channelGroup = NotifChannelGroup.PAYMENTS, + group = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION", + summaryId = 0x20000, + colorized = true, + ), + MEDIA( + channelIdRes = R.string.app_notification_media_channel_id, + channelNameRes = R.string.app_notification_media_channel_name, + channelDescriptionRes = R.string.app_notification_media_channel_description, + summaryTextRes = R.string.app_notification_media_summary, + importance = NotificationManager.IMPORTANCE_DEFAULT, + color = 0xFF00BCD4.toInt(), // cyan + smallIcon = R.drawable.ic_notif_media, + settingsIcon = MaterialSymbols.Image, + channelGroup = NotifChannelGroup.CONTENT, + group = "com.vitorpamplona.amethyst.MEDIA_NOTIFICATION", + summaryId = 0x80000, + ), + ARTICLE( + channelIdRes = R.string.app_notification_articles_channel_id, + channelNameRes = R.string.app_notification_articles_channel_name, + channelDescriptionRes = R.string.app_notification_articles_channel_description, + summaryTextRes = R.string.app_notification_articles_summary, + importance = NotificationManager.IMPORTANCE_DEFAULT, + color = 0xFF3F51B5.toInt(), // indigo + smallIcon = R.drawable.ic_notif_article, + settingsIcon = MaterialSymbols.Description, + channelGroup = NotifChannelGroup.CONTENT, + group = "com.vitorpamplona.amethyst.ARTICLE_NOTIFICATION", + summaryId = 0x90000, + ), + CODE( + channelIdRes = R.string.app_notification_code_channel_id, + channelNameRes = R.string.app_notification_code_channel_name, + channelDescriptionRes = R.string.app_notification_code_channel_description, + summaryTextRes = R.string.app_notification_code_summary, + importance = NotificationManager.IMPORTANCE_DEFAULT, + color = 0xFF607D8B.toInt(), // slate + smallIcon = R.drawable.ic_notif_code, + settingsIcon = MaterialSymbols.Code, + channelGroup = NotifChannelGroup.DEVELOPER, + group = "com.vitorpamplona.amethyst.CODE_NOTIFICATION", + summaryId = 0xA0000, + ), + BADGE( + channelIdRes = R.string.app_notification_badges_channel_id, + channelNameRes = R.string.app_notification_badges_channel_name, + channelDescriptionRes = R.string.app_notification_badges_channel_description, + summaryTextRes = R.string.app_notification_badges_summary, + importance = NotificationManager.IMPORTANCE_DEFAULT, + color = 0xFFFFC107.toInt(), // amber / gold + smallIcon = R.drawable.ic_notif_badge, + settingsIcon = MaterialSymbols.MilitaryTech, + channelGroup = NotifChannelGroup.SOCIAL, + group = "com.vitorpamplona.amethyst.BADGE_NOTIFICATION", + summaryId = 0xB0000, + ), + CHESS( + channelIdRes = R.string.app_notification_chess_channel_id, + channelNameRes = R.string.app_notification_chess_channel_name, + channelDescriptionRes = R.string.app_notification_chess_channel_description, + summaryTextRes = R.string.app_notification_chess_summary, + importance = NotificationManager.IMPORTANCE_DEFAULT, + color = 0xFF795548.toInt(), // brown + smallIcon = R.drawable.ic_notif_chess, + settingsIcon = MaterialSymbols.ChessKnight, + channelGroup = NotifChannelGroup.GAMES, + group = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION", + summaryId = 0x30000, + ), + ; + + fun channelId(context: Context): String = stringRes(context, channelIdRes) + + /** + * Idempotently creates this category's channel group and channel. Safe to + * call before every post — Android no-ops when the channel already exists + * (it never downgrades a channel the user has customized). Returns the + * channel id to post on. + */ + fun ensureChannel(context: Context): String { + val nm = context.getSystemService(NotificationManager::class.java) + nm.createNotificationChannelGroup( + NotificationChannelGroup(channelGroup.id, stringRes(context, channelGroup.nameRes)), + ) + val id = channelId(context) + val channel = + NotificationChannel(id, stringRes(context, channelNameRes), importance).apply { + description = stringRes(context, channelDescriptionRes) + group = channelGroup.id + } + nm.createNotificationChannel(channel) + return id + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt index 945481bc13..9559161a61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt @@ -62,57 +62,32 @@ object NotificationChannels { val ensure: (Context) -> Unit, ) + /** The per-kind content channels, derived from [NotificationCategory], in a + * sensible settings order, plus the two non-event channels (scheduled posts, + * calls) that don't map to a Nostr event kind. */ val contentChannels: List = - listOf( + NotificationCategory.entries.map { category -> Entry( - nameRes = R.string.app_notification_dms_channel_name, - icon = MaterialSymbols.Mail, - channelId = { stringRes(it, R.string.app_notification_dms_channel_id) }, - ensure = { NotificationUtils.getOrCreateDMChannel(it) }, - ), - Entry( - nameRes = R.string.app_notification_mentions_channel_name, - icon = MaterialSymbols.AlternateEmail, - channelId = { stringRes(it, R.string.app_notification_mentions_channel_id) }, - ensure = { NotificationUtils.getOrCreateMentionChannel(it) }, - ), - Entry( - nameRes = R.string.app_notification_replies_channel_name, - icon = MaterialSymbols.Chat, - channelId = { stringRes(it, R.string.app_notification_replies_channel_id) }, - ensure = { NotificationUtils.getOrCreateReplyChannel(it) }, - ), - Entry( - nameRes = R.string.app_notification_reactions_channel_name, - icon = MaterialSymbols.Favorite, - channelId = { stringRes(it, R.string.app_notification_reactions_channel_id) }, - ensure = { NotificationUtils.getOrCreateReactionChannel(it) }, - ), - Entry( - nameRes = R.string.app_notification_zaps_channel_name, - icon = MaterialSymbols.Bolt, - channelId = { stringRes(it, R.string.app_notification_zaps_channel_id) }, - ensure = { NotificationUtils.getOrCreateZapChannel(it) }, - ), - Entry( - nameRes = R.string.app_notification_chess_channel_name, - icon = MaterialSymbols.ChessKnight, - channelId = { stringRes(it, R.string.app_notification_chess_channel_id) }, - ensure = { NotificationUtils.getOrCreateChessChannel(it) }, - ), - Entry( - nameRes = R.string.app_notification_scheduled_posts_channel_name, - icon = MaterialSymbols.Schedule, - channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) }, - ensure = { AndroidScheduledPostNotifier.ensureChannel(it) }, - ), - Entry( - nameRes = R.string.app_notification_calls_channel_name, - icon = MaterialSymbols.Call, - channelId = { CallNotifier.CALL_CHANNEL_ID }, - ensure = { CallNotifier.getOrCreateCallChannel(it) }, - ), - ) + nameRes = category.channelNameRes, + icon = category.settingsIcon, + channelId = { category.channelId(it) }, + ensure = { category.ensureChannel(it) }, + ) + } + + listOf( + Entry( + nameRes = R.string.app_notification_scheduled_posts_channel_name, + icon = MaterialSymbols.Schedule, + channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) }, + ensure = { AndroidScheduledPostNotifier.ensureChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_calls_channel_name, + icon = MaterialSymbols.Call, + channelId = { CallNotifier.CALL_CHANNEL_ID }, + ensure = { CallNotifier.getOrCreateCallChannel(it) }, + ), + ) fun statusOf( context: Context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt new file mode 100644 index 0000000000..64505f5a22 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoEvent + +/** + * Content-extraction helpers shared by the per-kind notification renderers: + * decrypting private payloads, pulling a clean one-line excerpt, and resolving + * the media URL to show as a notification's big picture. + */ +object NotificationContent { + /** First non-blank line of [content], trimmed to [max] chars, or "" if none. */ + fun excerpt( + content: String?, + max: Int = 280, + ): String = + content + ?.split("\n") + ?.firstOrNull { it.isNotBlank() } + ?.take(max) + ?: "" + + suspend fun decryptZapContentAuthor( + event: LnZapRequestEvent, + signer: NostrSigner, + ): Event? = + if (event.isPrivateZap() && event.zappedAuthor().contains(event.pubKey)) { + signer.decryptZapEvent(event) + } else { + event + } + + suspend fun decryptContent( + note: Note, + signer: NostrSigner, + ): String? = + when (val event = note.event) { + is PrivateDmEvent -> event.decryptContent(signer) + is LnZapRequestEvent -> decryptZapContentAuthor(event, signer)?.content + else -> event?.content + } + + /** + * The primary image/thumbnail URL to render as a notification's big picture, + * or null if the event carries no displayable media. Pictures use their first + * imeta url; videos prefer the poster-frame `image`, falling back to the video + * url only when no poster is present. + */ + fun mediaImageUrl(event: Event?): String? = + when (event) { + is PictureEvent -> event.imetaTags().firstOrNull()?.url + is VideoEvent -> { + val meta = event.imetaTags().firstOrNull() + meta?.image?.firstOrNull() ?: meta?.url + } + else -> null + } +} 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 1f583ded83..c90a45b1ee 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 @@ -34,6 +34,8 @@ 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.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -41,8 +43,12 @@ 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.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -101,11 +107,15 @@ class NotificationDispatcher( // Direct-arrival PrivateDmEvent.KIND, LnZapEvent.KIND, + NutzapEvent.KIND, OnchainZapEvent.KIND, ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + BadgeAwardEvent.KIND, TextNoteEvent.KIND, CommentEvent.KIND, - // Public content kinds — routed to the Mentions channel when p-tagged. + // Public content kinds — routed to their channel when p-tagged. PictureEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, @@ -115,6 +125,8 @@ class NotificationDispatcher( PollEvent.KIND, GitPatchEvent.KIND, GitIssueEvent.KIND, + GitPullRequestEvent.KIND, + GitPullRequestUpdateEvent.KIND, HighlightEvent.KIND, LongTextNoteEvent.KIND, WikiNoteEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationEnricher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationEnricher.kt new file mode 100644 index 0000000000..9a2f19b7b2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationEnricher.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.content.Context +import android.os.PowerManager +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Makes a tray notification *observable*: it renders immediately from whatever + * is already in [com.vitorpamplona.amethyst.model.LocalCache], then — if the + * involved users' names/pictures or the involved notes' content/media haven't + * loaded yet — opens a bounded relay window, subscribes to those users and + * notes, and re-renders the same notification (replacing it in place) as the + * missing data arrives. + * + * This is the generalized form of the ad-hoc subscribe-and-wait that + * `notifyIncomingCall` and `wakeUpFor` do: on a cold push the process has no + * cached metadata, so without this a notification would show a raw pubkey and + * no avatar. With it, the notification fills in the moment the kind:0 (and the + * post itself, for media) lands. + * + * The re-render path relies on notifications being keyed by a stable id and on + * `setOnlyAlertOnce(true)` (see [PushNotifier]) so replacements update silently + * instead of re-buzzing. + */ +object NotificationEnricher { + private const val TAG = "NotificationEnricher" + private const val WINDOW_MS = 25_000L + + /** + * Posts [build] now, then observes [users] and [notes] for the enrichment + * window, re-running [build] whenever their metadata/content changes, until + * [isComplete] reports everything needed is present or the window elapses. + * + * Non-blocking: the observation runs detached on the app IO scope under its + * own wakelock, so the caller (the notification dispatcher) is never held up. + * When [isComplete] is already satisfied, no relay window is opened. + */ + fun enrichAndPost( + context: Context, + account: Account, + users: Collection, + notes: Collection, + isComplete: () -> Boolean, + build: suspend () -> Unit, + ) { + Amethyst.instance.applicationIOScope.launch { + // 1. Immediate render from whatever is already cached. + runBuild(build) + + // 2. If we already have everything, we're done — no relay window. + if (isComplete()) return@launch + + withEnrichmentWakeLock(context) { + observeUntilComplete(account, users, notes, isComplete, build) + } + } + } + + private suspend fun observeUntilComplete( + account: Account, + users: Collection, + notes: Collection, + isComplete: () -> Boolean, + build: suspend () -> Unit, + ) { + val userSubs = users.map { UserFinderQueryState(it, account) } + val noteSubs = notes.map { EventFinderQueryState(it, account) } + val authSub = ScreenAuthAccount(account) + + try { + Amethyst.instance.authCoordinator.subscribe(authSub) + userSubs.forEach { + Amethyst.instance.sources.userFinder + .subscribe(it) + } + noteSubs.forEach { + Amethyst.instance.sources.eventFinder + .subscribe(it) + } + + coroutineScope { + // Keep the relay pool connected for the duration of the window. + val relayJob = + launch { + try { + withTimeout(WINDOW_MS) { + Amethyst.instance.relayProxyClientConnector.relayServices + .collect() + } + } catch (_: CancellationException) { + // window elapsed or observation finished first + } + } + + // Re-render whenever any involved user's metadata or note's + // content changes; stop as soon as everything needed is present. + val changes = + ( + users.map { it.metadata().flow.map { } } + + notes.map { + it + .flow() + .metadata.stateFlow + .map { } + } + ) + + if (changes.isNotEmpty()) { + withTimeoutOrNull(WINDOW_MS) { + merge(*changes.toTypedArray()) + .onEach { runBuild(build) } + .first { isComplete() } + } + } + + relayJob.cancel() + } + } finally { + noteSubs.forEach { + Amethyst.instance.sources.eventFinder + .unsubscribe(it) + } + userSubs.forEach { + Amethyst.instance.sources.userFinder + .unsubscribe(it) + } + Amethyst.instance.authCoordinator.unsubscribe(authSub) + } + } + + private suspend fun runBuild(build: suspend () -> Unit) { + try { + build() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Notification build failed", e) + } + } + + private inline fun withEnrichmentWakeLock( + context: Context, + block: () -> T, + ): T { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val wakeLock = + powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "amethyst:notification_enrichment", + ) + wakeLock.acquire(WINDOW_MS + 5_000L) + try { + return block() + } finally { + if (wakeLock.isHeld) wakeLock.release() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt new file mode 100644 index 0000000000..e605fbaccf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRoutes.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.app.NotificationManager +import android.content.Context +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip19Bech32.toNpub + +/** Deep-link URIs consumed by `MainActivity.uriToRoute` when a notification is tapped. */ +object NotificationRoutes { + private const val ACCOUNT = "?account=" + private const val SCROLL_TO = "&scrollTo=" + + fun accountNpub(account: Account): String = + account.signer.pubKey + .hexToByteArray() + .toNpub() + + /** Opens the note directly (used for replies, mentions, DMs, media, git). */ + fun noteUri( + note: Note, + accountNpub: String, + ): String = note.toNEvent() + ACCOUNT + accountNpub + + /** Opens the Notifications tab, scrolled to [scrollToId] (used for zaps, reactions, chess). */ + fun notificationsUri( + accountNpub: String, + scrollToId: String, + ): String = "notifications$ACCOUNT$accountNpub$SCROLL_TO$scrollToId" + + /** Opens a Marmot group chatroom (welcome + group message). */ + fun marmotUri( + nostrGroupId: String, + accountNpub: String, + ): String = "marmot:$nostrGroupId$ACCOUNT$accountNpub" +} + +internal fun Context.notificationManager(): NotificationManager = ContextCompat.getSystemService(this, NotificationManager::class.java) as NotificationManager diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 1f5eb63239..a9ccc422c1 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 @@ -21,20 +21,22 @@ package com.vitorpamplona.amethyst.service.notifications import android.app.Notification -import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.graphics.BitmapShader import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF +import android.graphics.Shader import android.graphics.drawable.BitmapDrawable import android.service.notification.StatusBarNotification import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.app.RemoteInput +import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import coil3.SingletonImageLoader @@ -47,22 +49,21 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlin.math.min +/** + * Low-level tray-notification builder. Every per-kind renderer funnels through + * [postStandard] (BigText / BigPicture) or [postConversation] (MessagingStyle), + * both driven by a [NotificationCategory] that supplies the channel, accent + * color, status-bar icon, group, and summary. + * + * Notifications are keyed by `id.hashCode()` (the triggering event id) so that + * (a) reading the underlying post in-app clears the tray entry via + * [dismissNotificationForEvent], and (b) re-posting the same id from the + * enrichment path replaces the notification in place. `setOnlyAlertOnce(true)` + * keeps those replacements silent. + */ object NotificationUtils { - private var dmChannel: NotificationChannel? = null - 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 MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION" @@ -76,12 +77,8 @@ object NotificationUtils { const val KEY_MARMOT_REPLY_TO_INNER_ID = "key_marmot_reply_to_inner_id" const val KEY_MARMOT_REPLY_TO_INNER_AUTHOR = "key_marmot_reply_to_inner_author" - 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 + const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION" 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. @@ -92,249 +89,6 @@ object NotificationUtils { fun replyGroupKeyFor(threadRootId: String): String = "$REPLY_GROUP_KEY_PREFIX:$threadRootId" - fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { - if (dmChannel != null) return dmChannel!! - - dmChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_dms_channel_id), - stringRes(applicationContext, R.string.app_notification_dms_channel_name), - NotificationManager.IMPORTANCE_HIGH, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_dms_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(dmChannel!!) - - return dmChannel!! - } - - fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel { - if (zapChannel != null) return zapChannel!! - - zapChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_zaps_channel_id), - stringRes(applicationContext, R.string.app_notification_zaps_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_zaps_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(zapChannel!!) - - return zapChannel!! - } - - fun getOrCreateReactionChannel(applicationContext: Context): NotificationChannel { - if (reactionChannel != null) return reactionChannel!! - - reactionChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_reactions_channel_id), - stringRes(applicationContext, R.string.app_notification_reactions_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_reactions_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(reactionChannel!!) - - return reactionChannel!! - } - - fun getOrCreateChessChannel(applicationContext: Context): NotificationChannel { - if (chessChannel != null) return chessChannel!! - - chessChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_chess_channel_id), - stringRes(applicationContext, R.string.app_notification_chess_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_chess_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(chessChannel!!) - - return chessChannel!! - } - - fun getOrCreateReplyChannel(applicationContext: Context): NotificationChannel { - if (replyChannel != null) return replyChannel!! - - replyChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_replies_channel_id), - stringRes(applicationContext, R.string.app_notification_replies_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_replies_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(replyChannel!!) - - return replyChannel!! - } - - fun getOrCreateMentionChannel(applicationContext: Context): NotificationChannel { - if (mentionChannel != null) return mentionChannel!! - - mentionChannel = - NotificationChannel( - stringRes(applicationContext, R.string.app_notification_mentions_channel_id), - stringRes(applicationContext, R.string.app_notification_mentions_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = - stringRes(applicationContext, R.string.app_notification_mentions_channel_description) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(mentionChannel!!) - - return mentionChannel!! - } - - suspend fun NotificationManager.sendReactionNotification( - id: String, - messageBody: String, - messageTitle: String, - time: Long, - pictureUrl: String?, - uri: String, - applicationContext: Context, - emojiUrl: String? = null, - ) { - getOrCreateReactionChannel(applicationContext) - val channelId = stringRes(applicationContext, R.string.app_notification_reactions_channel_id) - - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - time = time, - pictureUrl = pictureUrl, - badgeUrl = emojiUrl, - uri = uri, - channelId = channelId, - notificationGroupKey = REACTION_GROUP_KEY, - category = NotificationCompat.CATEGORY_SOCIAL, - summaryId = REACTION_SUMMARY_ID, - summaryText = stringRes(applicationContext, R.string.app_notification_reactions_summary), - applicationContext = applicationContext, - ) - } - - suspend fun NotificationManager.sendChessNotification( - id: String, - messageBody: String, - messageTitle: String, - time: Long, - pictureUrl: String?, - uri: String, - applicationContext: Context, - ) { - getOrCreateChessChannel(applicationContext) - val channelId = stringRes(applicationContext, R.string.app_notification_chess_channel_id) - - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - time = time, - pictureUrl = pictureUrl, - uri = uri, - channelId = channelId, - notificationGroupKey = CHESS_GROUP_KEY, - category = NotificationCompat.CATEGORY_SOCIAL, - summaryId = CHESS_SUMMARY_ID, - summaryText = stringRes(applicationContext, R.string.app_notification_chess_summary), - applicationContext = applicationContext, - ) - } - - 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 @@ -346,70 +100,325 @@ object NotificationUtils { val targetEventId: String, ) - suspend fun NotificationManager.sendZapNotification( + /** + * Wiring for a direct-message / group inline reply action. One of the three + * shapes routes to the matching action in [NotificationReplyReceiver]. + */ + sealed interface ReplyAction { + data class Dm( + val accountNpub: String, + val chatroomMembers: String, + ) : ReplyAction + + data class Marmot( + val accountNpub: String, + val nostrGroupId: String, + val replyToInnerEventId: String?, + val replyToInnerAuthor: String?, + ) : ReplyAction + } + + /** A prior message rendered above the main one in a MessagingStyle notification (thread context). */ + data class ParentMessage( + val senderName: String, + val body: String, + val pictureUrl: String?, + ) + + // --------------------------------------------------------------------- + // Standard notification (BigText, or BigPicture when [bigPictureUrl] set) + // --------------------------------------------------------------------- + + suspend fun NotificationManager.postStandard( + category: NotificationCategory, id: String, - messageBody: String, messageTitle: String, - time: Long, - pictureUrl: String?, - uri: String, - applicationContext: Context, - ) { - getOrCreateZapChannel(applicationContext) - val channelId = stringRes(applicationContext, R.string.app_notification_zaps_channel_id) - - sendNotification( - id = id, - messageBody = messageBody, - messageTitle = messageTitle, - time = time, - pictureUrl = pictureUrl, - uri = uri, - channelId = channelId, - notificationGroupKey = ZAP_GROUP_KEY, - category = NotificationCompat.CATEGORY_SOCIAL, - summaryId = ZAP_SUMMARY_ID, - summaryText = stringRes(applicationContext, R.string.app_notification_zaps_summary), - applicationContext = applicationContext, - ) - } - - suspend fun NotificationManager.sendDMNotification( - id: String, messageBody: String, - senderName: String, time: Long, pictureUrl: String?, uri: String, applicationContext: Context, - accountNpub: String? = null, - accountPictureUrl: String? = null, - chatroomMembers: String? = null, - marmotNostrGroupId: String? = null, - marmotReplyToInnerEventId: String? = null, - marmotReplyToInnerAuthor: String? = null, + bigPictureUrl: String? = null, + badgeUrl: String? = null, + inlineReply: InlineReplyTarget? = null, + groupKey: String = category.group, + summaryId: Int = category.summaryId, ) { - getOrCreateDMChannel(applicationContext) - val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id) + val channelId = category.ensureChannel(applicationContext) + val notId = id.hashCode() - sendDMNotificationStyled( - id = id, - messageBody = messageBody, - senderName = senderName, - time = time, - pictureUrl = pictureUrl, - uri = uri, - channelId = channelId, - applicationContext = applicationContext, - accountNpub = accountNpub, - accountPictureUrl = accountPictureUrl, - chatroomMembers = chatroomMembers, - marmotNostrGroupId = marmotNostrGroupId, - marmotReplyToInnerEventId = marmotReplyToInnerEventId, - marmotReplyToInnerAuthor = marmotReplyToInnerAuthor, + val avatar = pictureUrl?.let { loadBitmap(it, applicationContext) }?.let { circleCrop(it) } + val largeIcon = badgeUrl?.let { overlayBadge(avatar, it, applicationContext) } ?: avatar + val bigPicture = bigPictureUrl?.let { loadBitmap(it, applicationContext) } + + val contentPendingIntent = contentIntent(applicationContext, notId, uri) + + val builderPublic = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(category.smallIcon) + .setColor(category.color) + .setContentTitle(messageTitle) + .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) + .setContentIntent(contentPendingIntent) + .setPriority(category.priority()) + .setAutoCancel(true) + .setWhen(time * 1000) + + val builder = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(category.smallIcon) + .setColor(category.color) + .setContentTitle(messageTitle) + .setContentText(messageBody) + .setLargeIcon(largeIcon) + .setContentIntent(contentPendingIntent) + .setPublicVersion(builderPublic.build()) + .setPriority(category.priority()) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setGroup(groupKey) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setWhen(time * 1000) + + if (category.colorized) builder.setColorized(true) + + if (bigPicture != null) { + builder.setStyle( + NotificationCompat + .BigPictureStyle() + .bigPicture(bigPicture) + .bigLargeIcon(null as Bitmap?), + ) + } else { + builder.setStyle(NotificationCompat.BigTextStyle().bigText(messageBody)) + } + + if (inlineReply != null) { + builder.addAction(publicReplyAction(applicationContext, notId, inlineReply)) + } + + notify(notId, builder.build()) + sendGroupSummary(category, groupKey, summaryId, applicationContext) + } + + // --------------------------------------------------------------------- + // Conversation notification (MessagingStyle: DMs, replies, group chat) + // --------------------------------------------------------------------- + + suspend fun NotificationManager.postConversation( + category: NotificationCategory, + id: String, + senderName: String, + pictureUrl: String?, + messageBody: String, + time: Long, + uri: String, + applicationContext: Context, + accountPictureUrl: String? = null, + parent: ParentMessage? = null, + replyAction: ReplyAction? = null, + publicInlineReply: InlineReplyTarget? = null, + addMarkRead: Boolean = true, + groupKey: String = category.group, + summaryId: Int = category.summaryId, + ) { + val channelId = category.ensureChannel(applicationContext) + val notId = id.hashCode() + + val avatar = pictureUrl?.let { loadBitmap(it, applicationContext) }?.let { circleCrop(it) } + val accountAvatar = accountPictureUrl?.let { loadBitmap(it, applicationContext) }?.let { circleCrop(it) } + + val sender = + Person + .Builder() + .setName(senderName) + .apply { avatar?.let { setIcon(IconCompat.createWithBitmap(it)) } } + .build() + + val me = + Person + .Builder() + .setName(stringRes(applicationContext, R.string.app_notification_me)) + .apply { accountAvatar?.let { setIcon(IconCompat.createWithBitmap(it)) } } + .build() + + val messagingStyle = NotificationCompat.MessagingStyle(me) + + if (parent != null) { + val parentAvatar = parent.pictureUrl?.let { loadBitmap(it, applicationContext) }?.let { circleCrop(it) } + val parentSender = + Person + .Builder() + .setName(parent.senderName) + .apply { parentAvatar?.let { setIcon(IconCompat.createWithBitmap(it)) } } + .build() + messagingStyle.addMessage(parent.body, (time - 1) * 1000, parentSender) + } + messagingStyle.addMessage(messageBody, time * 1000, sender) + + val contentPendingIntent = contentIntent(applicationContext, notId, uri) + + val builderPublic = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(category.smallIcon) + .setColor(category.color) + .setContentTitle(senderName) + .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) + .setLargeIcon(avatar) + .setContentIntent(contentPendingIntent) + .setPriority(category.priority()) + .setAutoCancel(true) + .setWhen(time * 1000) + + val builder = + NotificationCompat + .Builder(applicationContext, channelId) + .setSmallIcon(category.smallIcon) + .setColor(category.color) + .setLargeIcon(avatar) + .setStyle(messagingStyle) + .setContentIntent(contentPendingIntent) + .setPublicVersion(builderPublic.build()) + .setPriority(category.priority()) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(groupKey) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setWhen(time * 1000) + + when (replyAction) { + is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, replyAction)) + is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, replyAction)) + null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, it)) } + } + + if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId)) + + notify(notId, builder.build()) + sendGroupSummary(category, groupKey, summaryId, applicationContext) + } + + // --------------------------------------------------------------------- + // Intents & actions + // --------------------------------------------------------------------- + + private fun contentIntent( + applicationContext: Context, + notId: Int, + uri: String, + ): PendingIntent { + val contentIntent = + Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } + return PendingIntent.getActivity( + applicationContext, + notId, + contentIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) } + private fun replyRemoteInput(applicationContext: Context): RemoteInput = + RemoteInput + .Builder(KEY_REPLY_TEXT) + .setLabel(stringRes(applicationContext, R.string.app_notification_reply_label)) + .build() + + private fun buildReplyAction( + applicationContext: Context, + notId: Int, + intent: Intent, + ): NotificationCompat.Action { + val replyPendingIntent = + PendingIntent.getBroadcast( + applicationContext, + notId, + intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return NotificationCompat.Action + .Builder(R.drawable.ic_notif_reply, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent) + .addRemoteInput(replyRemoteInput(applicationContext)) + .setAllowGeneratedReplies(true) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .build() + } + + private fun dmReplyAction( + applicationContext: Context, + notId: Int, + action: ReplyAction.Dm, + ): NotificationCompat.Action { + val intent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + this.action = REPLY_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_ACCOUNT_NPUB, action.accountNpub) + putExtra(KEY_CHATROOM_MEMBERS, action.chatroomMembers) + } + return buildReplyAction(applicationContext, notId, intent) + } + + private fun marmotReplyAction( + applicationContext: Context, + notId: Int, + action: ReplyAction.Marmot, + ): NotificationCompat.Action { + val intent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + this.action = MARMOT_REPLY_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_ACCOUNT_NPUB, action.accountNpub) + putExtra(KEY_MARMOT_GROUP_ID, action.nostrGroupId) + action.replyToInnerEventId?.let { putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, it) } + action.replyToInnerAuthor?.let { putExtra(KEY_MARMOT_REPLY_TO_INNER_AUTHOR, it) } + } + return buildReplyAction(applicationContext, notId, intent) + } + + private fun publicReplyAction( + applicationContext: Context, + notId: Int, + target: InlineReplyTarget, + ): NotificationCompat.Action { + val intent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + action = PUBLIC_REPLY_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_ACCOUNT_NPUB, target.accountNpub) + putExtra(KEY_TARGET_EVENT_ID, target.targetEventId) + } + return buildReplyAction(applicationContext, notId, intent) + } + + private fun markReadAction( + applicationContext: Context, + notId: Int, + ): NotificationCompat.Action { + val markReadIntent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + action = MARK_READ_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + } + val markReadPendingIntent = + PendingIntent.getBroadcast( + applicationContext, + notId + 1, + markReadIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return NotificationCompat.Action + .Builder(R.drawable.ic_notif_message, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .build() + } + + // --------------------------------------------------------------------- + // Bitmap helpers + // --------------------------------------------------------------------- + private suspend fun loadBitmap( pictureUrl: String, applicationContext: Context, @@ -430,6 +439,32 @@ object NotificationUtils { } } + /** Crops [src] to a centered circle so avatars render round in the tray. */ + private suspend fun circleCrop(src: Bitmap): Bitmap = + withContext(Dispatchers.Default) { + try { + val size = min(src.width, src.height) + val squared = + if (src.width != src.height) { + Bitmap.createBitmap(src, (src.width - size) / 2, (src.height - size) / 2, size, size) + } else { + src + } + val output = createBitmap(size, size) + val canvas = Canvas(output) + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + isFilterBitmap = true + shader = BitmapShader(squared, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + } + val r = size / 2f + canvas.drawCircle(r, r, r, paint) + output + } catch (_: Exception) { + src + } + } + /** * Draws the badge image (e.g. a NIP-30 custom-emoji reaction) onto the bottom-right corner * of the base avatar. Returns the base unchanged if the badge can't be loaded, or the badge @@ -446,7 +481,7 @@ object NotificationUtils { return withContext(Dispatchers.Default) { val result = base.copy(base.config ?: Bitmap.Config.ARGB_8888, true) val canvas = Canvas(result) - val badgeSize = minOf(result.width, result.height) * 0.45f + val badgeSize = min(result.width, result.height) * 0.45f val dest = RectF( result.width - badgeSize, @@ -460,303 +495,14 @@ object NotificationUtils { } } - private suspend fun NotificationManager.sendDMNotificationStyled( - id: String, - messageBody: String, - senderName: String, - time: Long, - pictureUrl: String?, - uri: String, - channelId: String, - applicationContext: Context, - accountNpub: String?, - accountPictureUrl: String?, - chatroomMembers: String?, - marmotNostrGroupId: String? = null, - marmotReplyToInnerEventId: String? = null, - marmotReplyToInnerAuthor: String? = null, - ) { - val notId = id.hashCode() - - if (isDuplicate(notId)) return - - val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) } - val accountBitmap = accountPictureUrl?.let { loadBitmap(it, applicationContext) } - - val senderIcon = bitmap?.let { IconCompat.createWithBitmap(it) } - val accountIcon = accountBitmap?.let { IconCompat.createWithBitmap(it) } - - val sender = - Person - .Builder() - .setName(senderName) - .apply { senderIcon?.let { setIcon(it) } } - .build() - - val messagingStyle = - NotificationCompat - .MessagingStyle( - Person - .Builder() - .setName("Me") - .setIcon(accountIcon) - .build(), - ).addMessage(messageBody, time * 1000, sender) - - val contentIntent = - Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } - - val contentPendingIntent = - PendingIntent.getActivity( - applicationContext, - notId, - contentIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val builderPublic = - NotificationCompat - .Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(senderName) - .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) - .setLargeIcon(bitmap) - .setContentIntent(contentPendingIntent) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .setWhen(time * 1000) - - val builder = - NotificationCompat - .Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.amethyst) - .setLargeIcon(bitmap) - .setStyle(messagingStyle) - .setContentIntent(contentPendingIntent) - .setPublicVersion(builderPublic.build()) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setGroup(DM_GROUP_KEY) - .setAutoCancel(true) - .setWhen(time * 1000) - - // Direct Reply action - if (accountNpub != null && chatroomMembers != 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 = REPLY_ACTION - putExtra(KEY_NOTIFICATION_ID, notId) - putExtra(KEY_ACCOUNT_NPUB, accountNpub) - putExtra(KEY_CHATROOM_MEMBERS, chatroomMembers) - } - - 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) - } else if (accountNpub != null && marmotNostrGroupId != null) { - // Marmot/MLS Reply action: sends the user's text as an encrypted - // kind:9 inside the Marmot group, replying to the inner event - // that triggered this notification. Mirrors the NIP-17 path - // above but routes through NotificationReplyReceiver's - // MARMOT_REPLY_ACTION branch so we never publish a plaintext - // public reply for an encrypted group message. - 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 = MARMOT_REPLY_ACTION - putExtra(KEY_NOTIFICATION_ID, notId) - putExtra(KEY_ACCOUNT_NPUB, accountNpub) - putExtra(KEY_MARMOT_GROUP_ID, marmotNostrGroupId) - if (marmotReplyToInnerEventId != null) { - putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, marmotReplyToInnerEventId) - } - if (marmotReplyToInnerAuthor != null) { - putExtra(KEY_MARMOT_REPLY_TO_INNER_AUTHOR, marmotReplyToInnerAuthor) - } - } - - 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) - } - - // Mark as Read action - val markReadIntent = - Intent(applicationContext, NotificationReplyReceiver::class.java).apply { - action = MARK_READ_ACTION - putExtra(KEY_NOTIFICATION_ID, notId) - } - - val markReadPendingIntent = - PendingIntent.getBroadcast( - applicationContext, - notId + 1, - markReadIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val markReadAction = - NotificationCompat.Action - .Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) - .build() - - builder.addAction(markReadAction) - - notify(notId, builder.build()) - - // Group summary notification - sendGroupSummary(channelId, DM_GROUP_KEY, DM_SUMMARY_ID, stringRes(applicationContext, R.string.app_notification_dms_summary), applicationContext) - } - - private suspend fun NotificationManager.sendNotification( - id: String, - messageBody: String, - messageTitle: String, - time: Long, - pictureUrl: String?, - uri: String, - channelId: String, - notificationGroupKey: String, - category: String, - summaryId: Int, - summaryText: String, - applicationContext: Context, - inlineReply: InlineReplyTarget? = null, - badgeUrl: String? = null, - ) { - val notId = id.hashCode() - - if (isDuplicate(notId)) return - - val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) } - - // For custom-emoji (NIP-30) reactions, the emoji is an image URL that can't render - // as text in the notification, so overlay it as a badge on the author's avatar. - val largeIcon = badgeUrl?.let { overlayBadge(bitmap, it, applicationContext) } ?: bitmap - - val contentIntent = - Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } - - val contentPendingIntent = - PendingIntent.getActivity( - applicationContext, - notId, - contentIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val builderPublic = - NotificationCompat - .Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(messageTitle) - .setContentText(stringRes(applicationContext, R.string.app_notification_private_message)) - .setLargeIcon(bitmap) - .setContentIntent(contentPendingIntent) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .setWhen(time * 1000) - - val builder = - NotificationCompat - .Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(messageTitle) - .setContentText(messageBody) - .setStyle(NotificationCompat.BigTextStyle().bigText(messageBody)) - .setLargeIcon(largeIcon) - .setContentIntent(contentPendingIntent) - .setPublicVersion(builderPublic.build()) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(category) - .setGroup(notificationGroupKey) - .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) - } + // --------------------------------------------------------------------- + // Group summaries, dedup, dismissal + // --------------------------------------------------------------------- private fun NotificationManager.sendGroupSummary( - channelId: String, + category: NotificationCategory, groupKey: String, summaryId: Int, - summaryText: String, applicationContext: Context, ) { val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId } @@ -765,30 +511,22 @@ object NotificationUtils { val summaryBuilder = NotificationCompat - .Builder(applicationContext, channelId) - .setSmallIcon(R.drawable.amethyst) + .Builder(applicationContext, category.channelId(applicationContext)) + .setSmallIcon(category.smallIcon) + .setColor(category.color) .setGroup(groupKey) .setGroupSummary(true) .setAutoCancel(true) + .setOnlyAlertOnce(true) .setStyle( NotificationCompat .InboxStyle() - .setSummaryText(summaryText), + .setSummaryText(stringRes(applicationContext, category.summaryTextRes)), ) notify(summaryId, summaryBuilder.build()) } - private fun NotificationManager.isDuplicate(notId: Int): Boolean { - val notifications: Array = activeNotifications - for (notification in notifications) { - if (notification.id == notId) { - return true - } - } - return false - } - /** Cancels all notifications. */ fun NotificationManager.cancelNotifications() { cancelAll() @@ -798,11 +536,11 @@ object NotificationUtils { * Dismisses the tray notification posted for [eventId] — used to auto-clear a * notification once the user reads the underlying event in-app. * - * Per-event notifications are keyed by `id.hashCode()` (see [sendNotification] - * and [sendDMNotificationStyled]), so hashing the same event id targets exactly - * the notification posted for it. Cancelling an id that isn't currently shown is - * a harmless no-op. After removing the child, any group summary left without - * children is cancelled too so the tray doesn't keep an empty summary around. + * Per-event notifications are keyed by `id.hashCode()`, so hashing the same + * event id targets exactly the notification posted for it. Cancelling an id + * that isn't currently shown is a harmless no-op. After removing the child, + * any group summary left without children is cancelled too so the tray + * doesn't keep an empty summary around. */ fun NotificationManager.dismissNotificationForEvent(eventId: HexKey) { val notId = eventId.hashCode() @@ -816,7 +554,7 @@ object NotificationUtils { } private fun NotificationManager.cancelChildlessGroupSummaries() { - val active = activeNotifications + val active: Array = activeNotifications for (summary in active) { if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue val group = summary.notification.group ?: continue @@ -824,4 +562,11 @@ object NotificationUtils { if (!hasChildren) cancel(summary.id) } } + + private fun NotificationCategory.priority(): Int = + when (importance) { + NotificationManager.IMPORTANCE_HIGH, NotificationManager.IMPORTANCE_MAX -> NotificationCompat.PRIORITY_HIGH + NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_MIN -> NotificationCompat.PRIORITY_LOW + else -> NotificationCompat.PRIORITY_DEFAULT + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt new file mode 100644 index 0000000000..731f194400 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ArticleNotification.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationContent +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent + +/** + * Article & highlight notifications — long-form (kind 30023), wiki (30818), and + * NIP-84 highlights (9802) that mention or highlight your writing. Rendered as an + * indigo card. Highlights show the highlighted passage; long-form/wiki mentions + * show the excerpt. Author name + avatar enriched observably. + */ +object ArticleNotification { + suspend fun notify( + context: Context, + account: Account, + event: Event, + ) { + val note = LocalCache.getNoteIfExists(event.id) ?: return + if (!account.isAcceptable(note)) return + + val author = LocalCache.getOrCreateUser(event.pubKey) + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.noteUri(note, accountNpub) + + val isHighlight = event is HighlightEvent + val titleRes = + if (isHighlight) { + R.string.app_notification_articles_channel_message_highlight + } else { + R.string.app_notification_articles_channel_message + } + val body = + if (event is HighlightEvent) { + NotificationContent.excerpt(event.quote()) + } else { + NotificationContent.excerpt(event.content) + } + + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(author), + notes = listOf(note), + isComplete = { author.metadataOrNull()?.bestName() != null }, + ) { + nm.postStandard( + category = NotificationCategory.ARTICLE, + id = event.id, + messageTitle = stringRes(context, titleRes, author.toBestDisplayName()), + messageBody = body, + time = event.createdAt, + pictureUrl = author.profilePicture(), + uri = uri, + applicationContext = context, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BadgeNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BadgeNotification.kt new file mode 100644 index 0000000000..7af11c9db9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/BadgeNotification.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent + +/** + * Badge-award notifications — NIP-58 kind 8. Rendered as a gold card + * ("You earned a badge", awarded by X). Issuer name + avatar enriched observably. + */ +object BadgeNotification { + suspend fun notify( + context: Context, + account: Account, + event: BadgeAwardEvent, + ) { + val note = LocalCache.getNoteIfExists(event.id) ?: return + if (!account.isAcceptable(note)) return + + val issuer = LocalCache.getOrCreateUser(event.pubKey) + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.notificationsUri(accountNpub, event.id) + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(issuer), + notes = listOf(note), + isComplete = { issuer.metadataOrNull()?.bestName() != null }, + ) { + nm.postStandard( + category = NotificationCategory.BADGE, + id = event.id, + messageTitle = stringRes(context, R.string.app_notification_badges_channel_message), + messageBody = stringRes(context, R.string.app_notification_badges_channel_message_from, issuer.toBestDisplayName()), + time = event.createdAt, + pictureUrl = issuer.profilePicture(), + uri = uri, + applicationContext = context, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ChessNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ChessNotification.kt new file mode 100644 index 0000000000..4db953d8f8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ChessNotification.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import androidx.annotation.StringRes +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent + +/** + * Chess game notifications — NIP-64 challenge-accepted and move events. Rendered + * as a brown card ("Chess" title, "X accepted your challenge" / "X moved — your + * turn"). Opponent name + avatar enriched observably. + */ +object ChessNotification { + suspend fun notify( + context: Context, + account: Account, + event: BaseChessEvent, + @StringRes contentRes: Int, + ) { + val author = LocalCache.getOrCreateUser(event.pubKey) + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.notificationsUri(accountNpub, event.id) + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(author), + notes = emptyList(), + isComplete = { author.metadataOrNull()?.bestName() != null }, + ) { + nm.postStandard( + category = NotificationCategory.CHESS, + id = event.id, + messageTitle = stringRes(context, R.string.app_notification_chess_channel_name), + messageBody = stringRes(context, contentRes, author.toBestDisplayName()), + time = event.createdAt, + pictureUrl = author.profilePicture(), + uri = uri, + applicationContext = context, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt new file mode 100644 index 0000000000..b92ab4b813 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/CodeNotification.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationContent +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent + +/** + * Git / code notifications — NIP-34 issues (1621), patches (1617), pull requests + * (1618) and PR updates (1619) on repos you maintain. Rendered as a slate card + * titled by the action ("X opened an issue" …) with the subject as the body. + * Author name + avatar enriched observably. + */ +object CodeNotification { + suspend fun notify( + context: Context, + account: Account, + event: GitIssueEvent, + ) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_issue, event.subject() ?: event.content) + + suspend fun notify( + context: Context, + account: Account, + event: GitPatchEvent, + ) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_patch, event.subject() ?: event.content) + + suspend fun notify( + context: Context, + account: Account, + event: GitPullRequestEvent, + ) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr, event.subject() ?: event.content) + + suspend fun notify( + context: Context, + account: Account, + event: GitPullRequestUpdateEvent, + ) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content) + + private suspend fun post( + context: Context, + account: Account, + id: String, + createdAt: Long, + authorPubkey: String, + titleRes: Int, + subject: String?, + ) { + val note = LocalCache.getNoteIfExists(id) ?: return + if (!account.isAcceptable(note)) return + + val author = LocalCache.getOrCreateUser(authorPubkey) + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.noteUri(note, accountNpub) + val body = NotificationContent.excerpt(subject, 140) + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(author), + notes = listOf(note), + isComplete = { author.metadataOrNull()?.bestName() != null }, + ) { + nm.postStandard( + category = NotificationCategory.CODE, + id = id, + messageTitle = stringRes(context, titleRes, author.toBestDisplayName()), + messageBody = body, + time = createdAt, + pictureUrl = author.profilePicture(), + uri = uri, + applicationContext = context, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt new file mode 100644 index 0000000000..b1a0121e11 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationContent +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent + +/** + * Direct-message notifications — NIP-17 chat (kind 14), NIP-17 encrypted files + * (kind 15), and legacy NIP-04 DMs (kind 4). Rendered with MessagingStyle so the + * shade shows the sender's avatar + name and the message threads under the + * Conversations section. NIP-17 messages carry an inline Reply action; NIP-04 is + * read-only (matching the historical behavior). + * + * The message body is resolved once up front; only the sender's name + avatar + * are enriched observably, so a cold-push notification fills in the sender's + * metadata as the kind:0 lands. + */ +object DirectMessageNotification { + suspend fun notify( + context: Context, + account: Account, + event: ChatMessageEvent, + ) = notifyRoom(context, account, event.id, event.createdAt, event.chatroomKey(account.signer.pubKey), decrypt = false) + + suspend fun notify( + context: Context, + account: Account, + event: ChatMessageEncryptedFileHeaderEvent, + ) = notifyRoom(context, account, event.id, event.createdAt, event.chatroomKey(account.signer.pubKey), decrypt = false) + + suspend fun notify( + context: Context, + account: Account, + event: PrivateDmEvent, + ) { + if (account.signer.pubKey != event.verifiedRecipientPubKey()) return + notifyRoom(context, account, event.id, event.createdAt, event.chatroomKey(account.signer.pubKey), decrypt = true) + } + + private suspend fun notifyRoom( + context: Context, + account: Account, + eventId: String, + createdAt: Long, + chatRoom: ChatroomKey, + decrypt: Boolean, + ) { + val chatNote = LocalCache.getNoteIfExists(eventId) ?: return + val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey) + val followingKeySet = account.followingKeySet() + + val isKnownRoom = + chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || + chatroomList.hasSentMessagesTo(chatRoom) + if (!isKnownRoom) return + + val author = chatNote.author ?: return + // Decrypt (NIP-04) or read (NIP-17) the body once — never re-decrypt on + // each enrichment tick, which could hammer a remote signer. + val body = + if (decrypt) { + NotificationContent.decryptContent(chatNote, account.signer) ?: return + } else { + chatNote.event?.content ?: return + } + + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.noteUri(chatNote, accountNpub) + val replyAction = + if (decrypt) { + null // NIP-04 is read-only in the tray + } else { + ReplyAction.Dm(accountNpub = accountNpub, chatroomMembers = chatRoom.users.joinToString(",")) + } + + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(author), + notes = listOf(chatNote), + isComplete = { author.metadataOrNull()?.bestName() != null }, + ) { + nm.postConversation( + category = NotificationCategory.DIRECT_MESSAGE, + id = eventId, + senderName = author.toBestDisplayName(), + pictureUrl = author.profilePicture(), + messageBody = body, + time = createdAt, + uri = uri, + applicationContext = context, + accountPictureUrl = account.userProfile().profilePicture(), + replyAction = replyAction, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/GroupMessageNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/GroupMessageNotification.kt new file mode 100644 index 0000000000..7a27d2e089 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/GroupMessageNotification.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Marmot / MLS group notifications — a kind:445 group message (rendered as a + * MessagingStyle chat with an encrypted inline reply) and a welcome invite + * ("you've been added to …"). These arrive without a `p` tag, so the dispatcher + * hands them here directly once the MLS layer has decrypted the inner event. + */ +object GroupMessageNotification { + suspend fun notifyGroupMessage( + context: Context, + account: Account, + innerEvent: ChatEvent, + nostrGroupId: String, + ) { + if (!context.notificationManager().areNotificationsEnabled()) return + if (MainActivity.isResumed) return + if (innerEvent.createdAt < TimeUtils.fifteenMinutesAgo()) return + if (innerEvent.pubKey == account.signer.pubKey) return + + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: DEFAULT_GROUP_NAME + val sender = LocalCache.getOrCreateUser(innerEvent.pubKey) + val fallbackBody = innerEvent.content.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.app_notification_new_message) + + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.marmotUri(nostrGroupId, accountNpub) + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(sender), + notes = emptyList(), + isComplete = { sender.metadataOrNull()?.bestName() != null }, + ) { + nm.postConversation( + category = NotificationCategory.DIRECT_MESSAGE, + id = innerEvent.id, + senderName = groupName, + pictureUrl = sender.profilePicture(), + messageBody = "${sender.toBestDisplayName()}: $fallbackBody", + time = innerEvent.createdAt, + uri = uri, + applicationContext = context, + accountPictureUrl = account.userProfile().profilePicture(), + replyAction = + ReplyAction.Marmot( + accountNpub = accountNpub, + nostrGroupId = nostrGroupId, + replyToInnerEventId = innerEvent.id, + replyToInnerAuthor = innerEvent.pubKey, + ), + ) + } + } + + suspend fun notifyWelcome( + context: Context, + account: Account, + event: WelcomeEvent, + ) { + if (!context.notificationManager().areNotificationsEnabled()) return + if (MainActivity.isResumed) return + if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return + if (event.pubKey == account.signer.pubKey) return + + val nostrGroupId = event.nostrGroupId() ?: return + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PRIVATE_GROUP + val inviter = LocalCache.getOrCreateUser(event.pubKey) + + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.marmotUri(nostrGroupId, accountNpub) + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(inviter), + notes = emptyList(), + isComplete = { inviter.metadataOrNull()?.bestName() != null }, + ) { + nm.postConversation( + category = NotificationCategory.DIRECT_MESSAGE, + id = event.id, + senderName = inviter.toBestDisplayName(), + pictureUrl = inviter.profilePicture(), + messageBody = stringRes(context, R.string.app_notification_added_to_group, groupName), + time = event.createdAt, + uri = uri, + applicationContext = context, + accountPictureUrl = account.userProfile().profilePicture(), + replyAction = null, + ) + } + } + + private const val DEFAULT_GROUP_NAME = "Private group" + private const val DEFAULT_PRIVATE_GROUP = "a private group" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt new file mode 100644 index 0000000000..39ade9ed6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MediaNotification.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationContent +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip71Video.VideoEvent + +/** + * Media notifications — a picture (kind 20) or video (kinds 21/22/34235/34236) + * that mentions you. Rendered with BigPictureStyle so the shade shows the actual + * image (video poster frame) inline. The author's name + avatar are enriched + * observably; the media URL comes straight off the (already present) event. + */ +object MediaNotification { + suspend fun notify( + context: Context, + account: Account, + event: Event, + ) { + val note = LocalCache.getNoteIfExists(event.id) ?: return + if (!account.isAcceptable(note)) return + + val author = LocalCache.getOrCreateUser(event.pubKey) + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.noteUri(note, accountNpub) + val isVideo = event is VideoEvent + val bigPictureUrl = NotificationContent.mediaImageUrl(event) + val caption = NotificationContent.excerpt(event.content, 140) + + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(author), + notes = listOf(note), + isComplete = { author.metadataOrNull()?.bestName() != null }, + ) { + val user = author.toBestDisplayName() + val titleRes = + if (isVideo) { + R.string.app_notification_media_channel_message_video + } else { + R.string.app_notification_media_channel_message_photo + } + nm.postStandard( + category = NotificationCategory.MEDIA, + id = event.id, + messageTitle = stringRes(context, titleRes, user), + messageBody = caption, + time = event.createdAt, + pictureUrl = author.profilePicture(), + uri = uri, + applicationContext = context, + bigPictureUrl = bigPictureUrl, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt new file mode 100644 index 0000000000..c94b0ffb0a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/MentionNotification.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import androidx.annotation.StringRes +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationContent +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * Text-mention notifications — someone mentioned, quoted, or cited you in a note + * (kind 1), or asked a poll that tags you. Rendered as an accented BigText card + * titled "X mentioned you" with the post excerpt. The author's name + avatar and + * the post body are enriched observably. + * + * Media (picture/video), articles/highlights, and git events are richer and live + * in their own renderers; this covers plain text mentions and polls. + */ +object MentionNotification { + suspend fun notify( + context: Context, + account: Account, + event: Event, + category: NotificationCategory = NotificationCategory.MENTION, + @StringRes titleRes: Int = R.string.app_notification_mentions_channel_message, + ) { + val note = LocalCache.getNoteIfExists(event.id) ?: return + if (!account.isAcceptable(note)) return + + val author = LocalCache.getOrCreateUser(event.pubKey) + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.noteUri(note, accountNpub) + val body = NotificationContent.excerpt(event.content) + + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(author), + notes = listOf(note), + isComplete = { author.metadataOrNull()?.bestName() != null }, + ) { + nm.postStandard( + category = category, + id = event.id, + messageTitle = stringRes(context, titleRes, author.toBestDisplayName()), + messageBody = body, + time = event.createdAt, + pictureUrl = author.profilePicture(), + uri = uri, + applicationContext = context, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReactionNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReactionNotification.kt new file mode 100644 index 0000000000..928f823aaf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReactionNotification.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationContent +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji + +/** + * Reaction (like) notifications — kind 7. Rendered as a heart-accented card + * titled with the reactor's chosen emoji + name; the reacted-post excerpt is the + * body. NIP-30 custom emoji, which can't render as text, are shown as a badge + * overlaid on the reactor's avatar. The reactor's name + avatar and the reacted + * post's content are enriched observably. + */ +object ReactionNotification { + private const val LIKE_EMOJI = "🤙" // 🤙 + private const val DISLIKE_EMOJI = "👎" // 👎 + + suspend fun notify( + context: Context, + account: Account, + event: ReactionEvent, + ) { + // NIP-25: the LAST `e` tag is the note actually reacted to. + val reactedPostId = event.originalPost().lastOrNull() ?: return + val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId) + if (reactedNote != null && !account.isAcceptable(reactedNote)) return + + val author = LocalCache.getOrCreateUser(event.pubKey) + val reactionContent = event.content + val customEmojiUrl = CustomEmoji.createEmojiMap(event.tags)[reactionContent] + + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.notificationsUri(accountNpub, event.id) + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(author), + notes = listOfNotNull(reactedNote), + isComplete = { author.metadataOrNull()?.bestName() != null }, + ) { + val user = author.toBestDisplayName() + val title = + if (customEmojiUrl != null) { + user + } else { + "${symbolFor(reactionContent)} $user" + } + val reactedContent = NotificationContent.excerpt(reactedNote?.event?.content, 140) + val body = + if (reactedContent.isNotBlank()) { + stringRes(context, R.string.app_notification_reactions_channel_message_for, reactedContent) + } else { + stringRes(context, R.string.app_notification_reactions_channel_message, user) + } + nm.postStandard( + category = NotificationCategory.REACTION, + id = event.id, + messageTitle = title, + messageBody = body, + time = event.createdAt, + pictureUrl = author.profilePicture(), + uri = uri, + applicationContext = context, + badgeUrl = customEmojiUrl, + ) + } + } + + private fun symbolFor(content: String): String = + when { + content == ReactionEvent.LIKE || content.isBlank() -> LIKE_EMOJI + content == ReactionEvent.DISLIKE -> DISLIKE_EMOJI + else -> content + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt new file mode 100644 index 0000000000..c137ba5da0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ReplyNotification.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationContent +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.InlineReplyTarget +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ParentMessage +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.replyGroupKeyFor +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.replySummaryIdFor +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * Reply notifications — someone replied to your note (NIP-10 kind 1, NIP-22 + * comment kind 1111, or a NIP-28 public-chat reply into your message). Rendered + * with MessagingStyle: the parent (your) message is shown as prior context, the + * reply as the latest message, and an inline Reply action lets you answer from + * the shade. Grouped per-thread so a busy thread collapses into one bundle. + * + * The replier's name + avatar are enriched observably. + */ +object ReplyNotification { + suspend fun notify( + context: Context, + account: Account, + event: Event, + parentContent: String?, + threadRootId: String, + ) { + val replyNote = LocalCache.getNoteIfExists(event.id) ?: return + if (!account.isAcceptable(replyNote)) return + + val author = LocalCache.getOrCreateUser(event.pubKey) + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.noteUri(replyNote, accountNpub) + + val replyExcerpt = NotificationContent.excerpt(event.content) + val parentExcerpt = parentContent?.let { NotificationContent.excerpt(it, 140) }?.takeIf { it.isNotBlank() } + + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(author), + notes = listOf(replyNote), + isComplete = { author.metadataOrNull()?.bestName() != null }, + ) { + val user = author.toBestDisplayName() + val parent = + parentExcerpt?.let { + ParentMessage( + senderName = stringRes(context, R.string.app_notification_me), + body = it, + pictureUrl = account.userProfile().profilePicture(), + ) + } + nm.postConversation( + category = NotificationCategory.REPLY, + id = event.id, + senderName = user, + pictureUrl = author.profilePicture(), + messageBody = replyExcerpt, + time = event.createdAt, + uri = uri, + applicationContext = context, + accountPictureUrl = account.userProfile().profilePicture(), + parent = parent, + publicInlineReply = InlineReplyTarget(accountNpub = accountNpub, targetEventId = event.id), + addMarkRead = false, + groupKey = replyGroupKeyFor(threadRootId), + summaryId = replySummaryIdFor(threadRootId), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/RepostNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/RepostNotification.kt new file mode 100644 index 0000000000..d904a6ccb6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/RepostNotification.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationContent +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent + +/** + * Repost / boost notifications — NIP-18 kind 6 and kind 16. Rendered as a + * green-accented card: "X reposted your post" with the reposted excerpt. The + * booster's name + avatar and the boosted post's content are enriched observably. + */ +object RepostNotification { + suspend fun notify( + context: Context, + account: Account, + event: RepostEvent, + ) = post(context, account, event.id, event.createdAt, event.pubKey, event.boostedEventId()) + + suspend fun notify( + context: Context, + account: Account, + event: GenericRepostEvent, + ) = post(context, account, event.id, event.createdAt, event.pubKey, event.boostedEventId()) + + private suspend fun post( + context: Context, + account: Account, + id: String, + createdAt: Long, + boosterPubkey: String, + boostedEventId: String?, + ) { + val boostedNote = boostedEventId?.let { LocalCache.checkGetOrCreateNote(it) } + if (boostedNote != null && !account.isAcceptable(boostedNote)) return + + val booster = LocalCache.getOrCreateUser(boosterPubkey) + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.notificationsUri(accountNpub, id) + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(booster), + notes = listOfNotNull(boostedNote), + isComplete = { booster.metadataOrNull()?.bestName() != null }, + ) { + nm.postStandard( + category = NotificationCategory.REPOST, + id = id, + messageTitle = stringRes(context, R.string.app_notification_reposts_channel_message, booster.toBestDisplayName()), + messageBody = NotificationContent.excerpt(boostedNote?.event?.content, 140), + time = createdAt, + pictureUrl = booster.profilePicture(), + uri = uri, + applicationContext = context, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt new file mode 100644 index 0000000000..0b0b4a44b4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications.renderers + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.notifications.NotificationCategory +import com.vitorpamplona.amethyst.service.notifications.NotificationContent +import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher +import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard +import com.vitorpamplona.amethyst.service.notifications.notificationManager +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import java.math.BigDecimal + +/** + * Zap notifications — Lightning (NIP-57, kind 9735), Cashu nutzaps (NIP-61, kind + * 9321), and onchain zaps (kind 8333). All render on the gold Zaps channel with a + * bolt icon; the title leads with the amount, the body names the sender and the + * zapped-post excerpt. The sender's name + avatar are enriched observably; for + * private Lightning zaps the sender is decrypted once up front. + */ +object ZapNotification { + private val MIN_ZAP_AMOUNT = BigDecimal.TEN + + suspend fun notify( + context: Context, + account: Account, + event: LnZapEvent, + ) { + LocalCache.getNoteIfExists(event.id) ?: return + val zapRequestNote = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return + val zappedNote = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return + if (!account.isAcceptable(zappedNote)) return + if ((event.amount ?: BigDecimal.ZERO) < MIN_ZAP_AMOUNT) return + + val zapRequestEvent = zapRequestNote.event as? LnZapRequestEvent ?: return + // Resolve the (possibly private) zapper once — never re-decrypt per tick. + val decrypted = NotificationContent.decryptZapContentAuthor(zapRequestEvent, account.signer) ?: return + val sender = LocalCache.getOrCreateUser(decrypted.pubKey) + val comment = decrypted.content.ifBlank { null } + val amount = showAmount(event.amount) + + post( + context = context, + account = account, + id = event.id, + createdAt = event.createdAt, + sender = sender, + zappedNote = zappedNote, + title = { _ -> zapTitle(context, amount, comment) }, + body = { user, excerpt -> fromLine(context, R.string.app_notification_zaps_channel_message_from, user, excerpt) }, + ) + } + + suspend fun notify( + context: Context, + account: Account, + event: NutzapEvent, + ) { + val zappedNote = event.linkedEventIds().lastOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } + if (zappedNote != null && !account.isAcceptable(zappedNote)) return + val sender = LocalCache.getOrCreateUser(event.pubKey) + + post( + context = context, + account = account, + id = event.id, + createdAt = event.createdAt, + sender = sender, + zappedNote = zappedNote, + title = { user -> stringRes(context, R.string.app_notification_nutzap_channel_message_from, user) }, + body = { user, excerpt -> excerpt.ifBlank { user } }, + ) + } + + suspend fun notify( + context: Context, + account: Account, + event: OnchainZapEvent, + ) { + val zappedNote = event.zappedEvent()?.let { LocalCache.checkGetOrCreateNote(it) } + if (zappedNote != null && !account.isAcceptable(zappedNote)) return + val sender = LocalCache.getOrCreateUser(event.pubKey) + val sats = event.claimedAmountInSats() + + post( + context = context, + account = account, + id = event.id, + createdAt = event.createdAt, + sender = sender, + zappedNote = zappedNote, + title = { user -> + if (sats != null) { + stringRes(context, R.string.app_notification_zaps_channel_message, showAmount(sats.toBigDecimal())) + } else { + stringRes(context, R.string.app_notification_onchain_channel_message_from, user) + } + }, + body = { user, excerpt -> fromLine(context, R.string.app_notification_onchain_channel_message_from, user, excerpt) }, + ) + } + + private suspend fun post( + context: Context, + account: Account, + id: String, + createdAt: Long, + sender: User, + zappedNote: Note?, + title: (String) -> String, + body: (String, String) -> String, + ) { + val accountNpub = NotificationRoutes.accountNpub(account) + val uri = NotificationRoutes.notificationsUri(accountNpub, id) + val nm = context.notificationManager() + + NotificationEnricher.enrichAndPost( + context = context, + account = account, + users = listOf(sender), + notes = listOfNotNull(zappedNote), + isComplete = { sender.metadataOrNull()?.bestName() != null }, + ) { + val user = sender.toBestDisplayName() + val excerpt = + zappedNote?.let { NotificationContent.excerpt(NotificationContent.decryptContent(it, account.signer), 140) } ?: "" + nm.postStandard( + category = NotificationCategory.ZAP, + id = id, + messageTitle = title(user), + messageBody = body(user, excerpt), + time = createdAt, + pictureUrl = sender.profilePicture(), + uri = uri, + applicationContext = context, + ) + } + } + + private fun zapTitle( + context: Context, + amount: String, + comment: String?, + ): String { + val base = stringRes(context, R.string.app_notification_zaps_channel_message, amount) + return if (comment != null) "$base ($comment)" else base + } + + private fun fromLine( + context: Context, + fromRes: Int, + user: String, + excerpt: String, + ): String { + var content = stringRes(context, fromRes, user) + if (excerpt.isNotBlank()) { + content += " " + stringRes(context, R.string.app_notification_zaps_channel_message_for, excerpt) + } + return content + } +} diff --git a/amethyst/src/main/res/drawable/ic_notif_article.xml b/amethyst/src/main/res/drawable/ic_notif_article.xml new file mode 100644 index 0000000000..33edc24b2b --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_article.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_badge.xml b/amethyst/src/main/res/drawable/ic_notif_badge.xml new file mode 100644 index 0000000000..2fee256783 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_badge.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_chess.xml b/amethyst/src/main/res/drawable/ic_notif_chess.xml new file mode 100644 index 0000000000..e20882a854 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_chess.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_code.xml b/amethyst/src/main/res/drawable/ic_notif_code.xml new file mode 100644 index 0000000000..d54a09f8ab --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_code.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_media.xml b/amethyst/src/main/res/drawable/ic_notif_media.xml new file mode 100644 index 0000000000..575a58dd8b --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_media.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_mention.xml b/amethyst/src/main/res/drawable/ic_notif_mention.xml new file mode 100644 index 0000000000..afb7e1bebd --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_mention.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_message.xml b/amethyst/src/main/res/drawable/ic_notif_message.xml new file mode 100644 index 0000000000..4c833fa3d9 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_message.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_reaction.xml b/amethyst/src/main/res/drawable/ic_notif_reaction.xml new file mode 100644 index 0000000000..9521a89868 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_reaction.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_reply.xml b/amethyst/src/main/res/drawable/ic_notif_reply.xml new file mode 100644 index 0000000000..cba9670de1 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_reply.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_repost.xml b/amethyst/src/main/res/drawable/ic_notif_repost.xml new file mode 100644 index 0000000000..845f99423c --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_repost.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_notif_zap.xml b/amethyst/src/main/res/drawable/ic_notif_zap.xml new file mode 100644 index 0000000000..77f822994d --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_notif_zap.xml @@ -0,0 +1,8 @@ + + + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a1560d7f82..243a42b1e5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1840,6 +1840,9 @@ Reply Mark Read + Me + New message + You\'ve been added to %1$s New messages New zaps @@ -1867,6 +1870,62 @@ %1$s mentioned you New mentions + + Messages + Social + Payments + Content + Developer + Games + + + RepostsID + Reposts + Notifies you when somebody reposts your post + %1$s reposted your post + New reposts + + + MediaID + Media + Notifies you when somebody mentions you in a photo or video + %1$s shared a photo + %1$s shared a video + New media + + + ArticlesID + Articles & Highlights + Notifies you when somebody mentions or highlights you in an article + %1$s mentioned you in an article + %1$s highlighted your article + New articles + + + CodeID + Code & Git + Notifies you about issues, patches, and pull requests + %1$s opened an issue + %1$s sent a patch + %1$s opened a pull request + %1$s updated a pull request + New code activity + + + BadgesID + Badges + Notifies you when you earn a badge + You earned a badge + Awarded by %1$s + New badges + + + Nutzap from %1$s + Onchain zap from %1$s + + + %1$s asked a question + Incoming calls Notifications for incoming voice and video calls diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index 064d951d04..a37f67260d 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -94,8 +94,8 @@ class PushNotificationReceiverService : FirebaseMessagingService() { PushNotificationUtils.checkAndInit(token, LocalPreferences.allSavedAccounts()) { Amethyst.instance.okHttpClients.getHttpClient(Amethyst.instance.torManager.isSocksReady()) } - NotificationUtils.getOrCreateZapChannel(applicationContext) - NotificationUtils.getOrCreateDMChannel(applicationContext) + NotificationCategory.ZAP.ensureChannel(applicationContext) + NotificationCategory.DIRECT_MESSAGE.ensureChannel(applicationContext) } }