Merge pull request #3307 from vitorpamplona/claude/custom-emojis-push-notifications-8n3h87

feat(notifications): render NIP-30 custom emoji reactions as avatar badge
This commit is contained in:
Vitor Pamplona
2026-06-20 12:43:27 -04:00
committed by GitHub
2 changed files with 60 additions and 8 deletions
@@ -69,6 +69,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
@@ -766,14 +767,24 @@ class EventNotificationConsumer(
val userPicture = author.profilePicture()
val reactionContent = event.content
val reactionSymbol =
when {
reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19"
reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E"
else -> reactionContent
}
val title = "$reactionSymbol $user"
// NIP-30 custom emoji: content is ":shortcode:" backed by an ["emoji", shortcode, url]
// tag. The image can't be rendered as text, so it is surfaced as a badge on the author's
// avatar (see sendReactionNotification) and the title keeps just the author's name.
val customEmojiUrl = CustomEmoji.createEmojiMap(event.tags)[reactionContent]
val title =
if (customEmojiUrl != null) {
user
} else {
val reactionSymbol =
when {
reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19"
reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E"
else -> reactionContent
}
"$reactionSymbol $user"
}
val reactedContent =
reactedNote
@@ -813,6 +824,7 @@ class EventNotificationConsumer(
userPicture,
noteUri,
applicationContext,
emojiUrl = customEmojiUrl,
)
}
@@ -26,6 +26,9 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.drawable.BitmapDrawable
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
@@ -221,6 +224,7 @@ object NotificationUtils {
pictureUrl: String?,
uri: String,
applicationContext: Context,
emojiUrl: String? = null,
) {
getOrCreateReactionChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_reactions_channel_id)
@@ -231,6 +235,7 @@ object NotificationUtils {
messageTitle = messageTitle,
time = time,
pictureUrl = pictureUrl,
badgeUrl = emojiUrl,
uri = uri,
channelId = channelId,
notificationGroupKey = REACTION_GROUP_KEY,
@@ -423,6 +428,36 @@ object NotificationUtils {
}
}
/**
* Draws the badge image (e.g. a NIP-30 custom-emoji reaction) onto the bottom-right corner
* of the base avatar. Returns the base unchanged if the badge can't be loaded, or the badge
* alone if there is no base avatar.
*/
private suspend fun overlayBadge(
base: Bitmap?,
badgeUrl: String,
applicationContext: Context,
): Bitmap? {
val badge = loadBitmap(badgeUrl, applicationContext) ?: return base
if (base == null) return badge
return withContext(Dispatchers.Default) {
val result = base.copy(base.config ?: Bitmap.Config.ARGB_8888, true)
val canvas = Canvas(result)
val badgeSize = minOf(result.width, result.height) * 0.45f
val dest =
RectF(
result.width - badgeSize,
result.height - badgeSize,
result.width.toFloat(),
result.height.toFloat(),
)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { isFilterBitmap = true }
canvas.drawBitmap(badge, null, dest, paint)
result
}
}
private suspend fun NotificationManager.sendDMNotificationStyled(
id: String,
messageBody: String,
@@ -625,6 +660,7 @@ object NotificationUtils {
summaryText: String,
applicationContext: Context,
inlineReply: InlineReplyTarget? = null,
badgeUrl: String? = null,
) {
val notId = id.hashCode()
@@ -632,6 +668,10 @@ object NotificationUtils {
val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) }
// For custom-emoji (NIP-30) reactions, the emoji is an image URL that can't render
// as text in the notification, so overlay it as a badge on the author's avatar.
val largeIcon = badgeUrl?.let { overlayBadge(bitmap, it, applicationContext) } ?: bitmap
val contentIntent =
Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() }
@@ -662,7 +702,7 @@ object NotificationUtils {
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setStyle(NotificationCompat.BigTextStyle().bigText(messageBody))
.setLargeIcon(bitmap)
.setLargeIcon(largeIcon)
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)