feat(notifications): per-kind, observable, beautifully-rendered push notifications

Rebuilds the Android tray-notification layer around a per-kind design system so
every Nostr notification reads at a glance and looks native to modern Android.

Framework:
- NotificationCategory: one table-driven entry per kind carrying its channel,
  accent color, monochrome status-bar icon, importance, group key, and settings
  icon. Reuses existing channel ids (no orphaned user settings) and adds
  Reposts/Media/Articles/Code/Badges channels, organized into system-settings
  channel groups (Messages/Social/Payments/Content/Developer/Games) so users can
  silence a single kind or a whole family.
- NotificationUtils: rewritten as a category-driven builder — postStandard
  (BigText, or BigPicture for media/badges) and postConversation (MessagingStyle
  for DMs/replies/chat/group). Adds setColor accents, circular avatars, colorized
  zaps, kept the NIP-30 emoji badge overlay, and setOnlyAlertOnce so enrichment
  updates replace silently.
- NotificationEnricher: makes notifications observable — renders immediately from
  cache, then subscribes to the involved npubs (author/zapper/chat members) and
  notes via userFinder/eventFinder, re-rendering in place as names, pictures,
  content, and post images arrive over a bounded relay window.

One file per notification (service/notifications/renderers/): DirectMessage,
GroupMessage (+welcome), Reply, Mention, Reaction, Zap (Lightning+nutzap+onchain),
Repost, Media, Article/Highlight, Code (git), Badge, Chess. EventNotificationConsumer
is now a slim policy dispatcher (account match + shared gates) delegating to them.

Closes push-vs-feed parity gaps: nutzaps (9321), onchain zaps (8333), reposts
(6/16), badge awards (8), and git PRs/updates (1618/1619) now render.

Dismiss-on-read is preserved (per-event id keying) so reading a post in-app
clears its tray notification. Adds monochrome status-bar drawables and the new
channel/title/group strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
This commit is contained in:
Claude
2026-07-23 05:16:08 +00:00
parent 6d8a2f0950
commit aba21c839f
34 changed files with 2557 additions and 1478 deletions
@@ -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" }
@@ -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
}
}
@@ -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<Entry> =
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,
@@ -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
}
}
@@ -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,
@@ -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<User>,
notes: Collection<Note>,
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<User>,
notes: Collection<Note>,
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 <T> 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()
}
}
}
@@ -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
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
@@ -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"
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
@@ -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
}
}
@@ -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),
)
}
}
}
@@ -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,
)
}
}
}
@@ -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
}
}
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M19,3H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM14,17H7v-2h7v2zM17,13H7v-2h10v2zM17,9H7V7h10v2z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M19,22H5v-2h14v2zM17.16,8.26C18.22,9.63 18.7,11.4 18.5,13.16C18.15,15.98 15.67,18 12.83,18H11.5C8.66,18 6.35,15.98 6,13.16c-0.2,-1.79 0.29,-3.56 1.34,-4.93C6.5,7.68 6,6.65 6,5.5 6,3.57 7.57,2 9.5,2c1.03,0 1.95,0.45 2.59,1.15C12.71,2.44 13.58,2 14.5,2 16.43,2 18,3.57 18,5.5c0,1.15 -0.5,2.18 -0.84,2.76z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M9.4,16.6L4.8,12l4.6,-4.6L8,6l-6,6 6,6 1.4,-1.4zM14.6,16.6l4.6,-4.6 -4.6,-4.6L16,6l6,6 -6,6 -1.4,-1.4z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10c1.87,0 3.63,-0.52 5.13,-1.42l-0.9,-1.55C15,19.65 13.55,20 12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8v0.72c0,0.79 -0.71,1.78 -1.5,1.78s-1.5,-0.99 -1.5,-1.78V12c0,-2.76 -2.24,-5 -5,-5S7,9.24 7,12s2.24,5 5,5c1.38,0 2.64,-0.56 3.54,-1.47 0.65,0.89 1.77,1.47 2.96,1.47 1.97,0 3.5,-1.6 3.5,-3.78V12c0,-5.52 -4.48,-10 -10,-10zM12,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M11,21h-1l1,-7H7.5c-0.58,0 -0.57,-0.32 -0.38,-0.66c0.19,-0.34 0.05,-0.08 0.07,-0.12C8.48,10.94 10.42,7.54 13,3h1l-1,7h3.5c0.49,0 0.56,0.33 0.47,0.51l-0.07,0.15C12.96,17.55 11,21 11,21z"/>
</vector>
+59
View File
@@ -1840,6 +1840,9 @@
<string name="app_notification_reply_label">Reply</string>
<string name="app_notification_mark_read_label">Mark Read</string>
<string name="app_notification_me">Me</string>
<string name="app_notification_new_message">New message</string>
<string name="app_notification_added_to_group">You\'ve been added to %1$s</string>
<string name="app_notification_dms_summary">New messages</string>
<string name="app_notification_zaps_summary">New zaps</string>
@@ -1867,6 +1870,62 @@
<string name="app_notification_mentions_channel_message">%1$s mentioned you</string>
<string name="app_notification_mentions_summary">New mentions</string>
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
<string name="app_notification_group_messages">Messages</string>
<string name="app_notification_group_social">Social</string>
<string name="app_notification_group_payments">Payments</string>
<string name="app_notification_group_content">Content</string>
<string name="app_notification_group_developer">Developer</string>
<string name="app_notification_group_games">Games</string>
<!-- Reposts -->
<string name="app_notification_reposts_channel_id" translatable="false">RepostsID</string>
<string name="app_notification_reposts_channel_name">Reposts</string>
<string name="app_notification_reposts_channel_description">Notifies you when somebody reposts your post</string>
<string name="app_notification_reposts_channel_message">%1$s reposted your post</string>
<string name="app_notification_reposts_summary">New reposts</string>
<!-- Media (picture &amp; video posts that mention you) -->
<string name="app_notification_media_channel_id" translatable="false">MediaID</string>
<string name="app_notification_media_channel_name">Media</string>
<string name="app_notification_media_channel_description">Notifies you when somebody mentions you in a photo or video</string>
<string name="app_notification_media_channel_message_photo">%1$s shared a photo</string>
<string name="app_notification_media_channel_message_video">%1$s shared a video</string>
<string name="app_notification_media_summary">New media</string>
<!-- Articles &amp; highlights -->
<string name="app_notification_articles_channel_id" translatable="false">ArticlesID</string>
<string name="app_notification_articles_channel_name">Articles &amp; Highlights</string>
<string name="app_notification_articles_channel_description">Notifies you when somebody mentions or highlights you in an article</string>
<string name="app_notification_articles_channel_message">%1$s mentioned you in an article</string>
<string name="app_notification_articles_channel_message_highlight">%1$s highlighted your article</string>
<string name="app_notification_articles_summary">New articles</string>
<!-- Code &amp; git -->
<string name="app_notification_code_channel_id" translatable="false">CodeID</string>
<string name="app_notification_code_channel_name">Code &amp; Git</string>
<string name="app_notification_code_channel_description">Notifies you about issues, patches, and pull requests</string>
<string name="app_notification_code_channel_message_issue">%1$s opened an issue</string>
<string name="app_notification_code_channel_message_patch">%1$s sent a patch</string>
<string name="app_notification_code_channel_message_pr">%1$s opened a pull request</string>
<string name="app_notification_code_channel_message_pr_update">%1$s updated a pull request</string>
<string name="app_notification_code_summary">New code activity</string>
<!-- Badges -->
<string name="app_notification_badges_channel_id" translatable="false">BadgesID</string>
<string name="app_notification_badges_channel_name">Badges</string>
<string name="app_notification_badges_channel_description">Notifies you when you earn a badge</string>
<string name="app_notification_badges_channel_message">You earned a badge</string>
<string name="app_notification_badges_channel_message_from">Awarded by %1$s</string>
<string name="app_notification_badges_summary">New badges</string>
<!-- Nutzaps &amp; onchain zaps (rendered on the Zaps channel) -->
<string name="app_notification_nutzap_channel_message_from">Nutzap from %1$s</string>
<string name="app_notification_onchain_channel_message_from">Onchain zap from %1$s</string>
<!-- Poll mentions (rendered on the Mentions channel) -->
<string name="app_notification_poll_channel_message">%1$s asked a question</string>
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Incoming calls</string>
<string name="app_notification_calls_channel_description">Notifications for incoming voice and video calls</string>
@@ -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)
}
}