mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
+2
-2
@@ -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" }
|
||||
|
||||
+136
-796
File diff suppressed because it is too large
Load Diff
+253
@@ -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
|
||||
}
|
||||
}
|
||||
+24
-49
@@ -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,
|
||||
|
||||
+83
@@ -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
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -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,
|
||||
|
||||
+194
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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
|
||||
+373
-628
File diff suppressed because it is too large
Load Diff
+91
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -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"
|
||||
}
|
||||
+88
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -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
|
||||
}
|
||||
}
|
||||
+103
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
@@ -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>
|
||||
@@ -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 & 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 & highlights -->
|
||||
<string name="app_notification_articles_channel_id" translatable="false">ArticlesID</string>
|
||||
<string name="app_notification_articles_channel_name">Articles & 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 & git -->
|
||||
<string name="app_notification_code_channel_id" translatable="false">CodeID</string>
|
||||
<string name="app_notification_code_channel_name">Code & 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 & 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>
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user