feat: improve rich chat notifications
- show image previews in photo notifications - localize non-text notification bodies by message type - use sender names as notification titles
This commit is contained in:
@@ -4,6 +4,8 @@ package ru.daemonlord.messenger.core.notifications
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@@ -18,6 +20,7 @@ import ru.daemonlord.messenger.MainActivity
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class NotificationDispatcher @Inject constructor(
|
class NotificationDispatcher @Inject constructor(
|
||||||
@@ -32,6 +35,7 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
NotificationChannels.CHANNEL_MESSAGES
|
NotificationChannels.CHANNEL_MESSAGES
|
||||||
}
|
}
|
||||||
|
val displayBody = payload.toDisplayBody(context)
|
||||||
|
|
||||||
val state = synchronized(chatStates) {
|
val state = synchronized(chatStates) {
|
||||||
val existing = chatStates[payload.chatId]
|
val existing = chatStates[payload.chatId]
|
||||||
@@ -40,7 +44,7 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
}
|
}
|
||||||
val updated = (existing ?: ChatNotificationState(title = payload.title))
|
val updated = (existing ?: ChatNotificationState(title = payload.title))
|
||||||
.copy(title = payload.title)
|
.copy(title = payload.title)
|
||||||
.appendMessage(payload.body, payload.messageId)
|
.appendMessage(displayBody, payload.messageId)
|
||||||
chatStates[payload.chatId] = updated
|
chatStates[payload.chatId] = updated
|
||||||
updated
|
updated
|
||||||
}
|
}
|
||||||
@@ -82,19 +86,21 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val contentText = when {
|
val contentText = when {
|
||||||
state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body
|
state.unreadCount <= 1 -> state.lines.firstOrNull() ?: displayBody
|
||||||
else -> "${state.unreadCount} new messages"
|
else -> context.getString(R.string.notification_messages_count, state.unreadCount)
|
||||||
}
|
}
|
||||||
val inboxStyle = NotificationCompat.InboxStyle()
|
val inboxStyle = NotificationCompat.InboxStyle()
|
||||||
.setBigContentTitle(state.title)
|
.setBigContentTitle(state.title)
|
||||||
.setSummaryText("${state.unreadCount} messages")
|
.setSummaryText(context.getString(R.string.notification_messages_count, state.unreadCount))
|
||||||
state.lines.reversed().forEach { inboxStyle.addLine(it) }
|
state.lines.reversed().forEach { inboxStyle.addLine(it) }
|
||||||
|
val bigPicture = payload.previewImageUrl
|
||||||
|
?.takeIf { it.isNotBlank() && state.unreadCount <= 1 && payload.messageType.equals("image", ignoreCase = true) }
|
||||||
|
?.let { loadNotificationBitmap(it) }
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, channelId)
|
val builder = NotificationCompat.Builder(context, channelId)
|
||||||
.setSmallIcon(R.drawable.ic_notification_small)
|
.setSmallIcon(R.drawable.ic_notification_small)
|
||||||
.setContentTitle(state.title)
|
.setContentTitle(payload.senderName?.ifBlank { null } ?: state.title)
|
||||||
.setContentText(contentText)
|
.setContentText(contentText)
|
||||||
.setStyle(inboxStyle)
|
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.setGroup(GROUP_KEY_CHATS)
|
.setGroup(GROUP_KEY_CHATS)
|
||||||
@@ -120,7 +126,19 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
.setAllowGeneratedReplies(true)
|
.setAllowGeneratedReplies(true)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.build()
|
if (bigPicture != null) {
|
||||||
|
builder.setLargeIcon(bigPicture)
|
||||||
|
.setStyle(
|
||||||
|
NotificationCompat.BigPictureStyle()
|
||||||
|
.bigPicture(bigPicture)
|
||||||
|
.bigLargeIcon(null as Bitmap?)
|
||||||
|
.setBigContentTitle(payload.senderName?.ifBlank { null } ?: state.title)
|
||||||
|
.setSummaryText(contentText)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
builder.setStyle(inboxStyle)
|
||||||
|
}
|
||||||
|
val notification = builder.build()
|
||||||
|
|
||||||
val manager = NotificationManagerCompat.from(context)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
manager.notifySafely(chatNotificationId(payload.chatId), notification)
|
manager.notifySafely(chatNotificationId(payload.chatId), notification)
|
||||||
@@ -145,7 +163,7 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
val totalUnread = snapshot.sumOf { it.unreadCount }
|
val totalUnread = snapshot.sumOf { it.unreadCount }
|
||||||
val inboxStyle = NotificationCompat.InboxStyle()
|
val inboxStyle = NotificationCompat.InboxStyle()
|
||||||
.setBigContentTitle("Benya Messenger")
|
.setBigContentTitle("Benya Messenger")
|
||||||
.setSummaryText("$totalUnread messages")
|
.setSummaryText(context.getString(R.string.notification_messages_count, totalUnread))
|
||||||
snapshot.take(6).forEach { state ->
|
snapshot.take(6).forEach { state ->
|
||||||
val preview = state.lines.firstOrNull().orEmpty()
|
val preview = state.lines.firstOrNull().orEmpty()
|
||||||
val line = if (preview.isBlank()) {
|
val line = if (preview.isBlank()) {
|
||||||
@@ -158,7 +176,7 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
val summary = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_MESSAGES)
|
val summary = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_MESSAGES)
|
||||||
.setSmallIcon(R.drawable.ic_notification_small)
|
.setSmallIcon(R.drawable.ic_notification_small)
|
||||||
.setContentTitle("Benya Messenger")
|
.setContentTitle("Benya Messenger")
|
||||||
.setContentText("$totalUnread new messages in ${snapshot.size} chats")
|
.setContentText(context.getString(R.string.notification_summary_count, totalUnread, snapshot.size))
|
||||||
.setStyle(inboxStyle)
|
.setStyle(inboxStyle)
|
||||||
.setGroup(GROUP_KEY_CHATS)
|
.setGroup(GROUP_KEY_CHATS)
|
||||||
.setGroupSummary(true)
|
.setGroupSummary(true)
|
||||||
@@ -188,6 +206,35 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
return abs((chatId * 1_000_003L).toInt())
|
return abs((chatId * 1_000_003L).toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadNotificationBitmap(url: String): Bitmap? {
|
||||||
|
return runCatching {
|
||||||
|
URL(url).openStream().use { input ->
|
||||||
|
BitmapFactory.decodeStream(input)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ChatNotificationPayload.toDisplayBody(context: Context): String {
|
||||||
|
val normalizedType = messageType?.trim()?.lowercase()
|
||||||
|
val raw = body.trim()
|
||||||
|
if (!raw.isBlank() && normalizedType == "text") {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return when (normalizedType) {
|
||||||
|
"image" -> {
|
||||||
|
if (raw.equals("photo", ignoreCase = true) || raw.equals("image", ignoreCase = true) || raw.isBlank()) {
|
||||||
|
context.getString(R.string.notification_type_photo)
|
||||||
|
} else raw
|
||||||
|
}
|
||||||
|
"video" -> if (raw.equals("video", ignoreCase = true) || raw.isBlank()) context.getString(R.string.notification_type_video) else raw
|
||||||
|
"audio" -> if (raw.equals("audio", ignoreCase = true) || raw.isBlank()) context.getString(R.string.notification_type_audio) else raw
|
||||||
|
"voice" -> if (raw.equals("voice message", ignoreCase = true) || raw.isBlank()) context.getString(R.string.notification_type_voice) else raw
|
||||||
|
"circle_video" -> if (raw.equals("video note", ignoreCase = true) || raw.isBlank()) context.getString(R.string.notification_type_circle) else raw
|
||||||
|
"file" -> if (raw.equals("file", ignoreCase = true) || raw.isBlank()) context.getString(R.string.notification_type_file) else raw
|
||||||
|
else -> raw.ifBlank { context.getString(R.string.notification_type_message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private data class ChatNotificationState(
|
private data class ChatNotificationState(
|
||||||
val title: String,
|
val title: String,
|
||||||
val unreadCount: Int = 0,
|
val unreadCount: Int = 0,
|
||||||
@@ -195,7 +242,7 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
val lastMessageId: Long? = null,
|
val lastMessageId: Long? = null,
|
||||||
) {
|
) {
|
||||||
fun appendMessage(body: String, messageId: Long?): ChatNotificationState {
|
fun appendMessage(body: String, messageId: Long?): ChatNotificationState {
|
||||||
val normalized = body.trim().ifBlank { "New message" }
|
val normalized = body.trim().ifBlank { "Message" }
|
||||||
val updatedLines = buildList {
|
val updatedLines = buildList {
|
||||||
add(normalized)
|
add(normalized)
|
||||||
lines.forEach { add(it) }
|
lines.forEach { add(it) }
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ data class ChatNotificationPayload(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val body: String,
|
val body: String,
|
||||||
val isMention: Boolean = false,
|
val isMention: Boolean = false,
|
||||||
|
val messageType: String? = null,
|
||||||
|
val previewImageUrl: String? = null,
|
||||||
|
val senderName: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
object NotificationIntentExtras {
|
object NotificationIntentExtras {
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ object PushPayloadParser {
|
|||||||
title = title,
|
title = title,
|
||||||
body = body,
|
body = body,
|
||||||
isMention = isMention,
|
isMention = isMention,
|
||||||
|
messageType = data["message_type"] ?: data["messageType"],
|
||||||
|
previewImageUrl = data["preview_image_url"] ?: data["previewImageUrl"],
|
||||||
|
senderName = data["sender_name"] ?: data["senderName"],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,15 @@
|
|||||||
<string name="notification_action_reply">Ответить</string>
|
<string name="notification_action_reply">Ответить</string>
|
||||||
<string name="notification_action_reply_hint">Введите ответ</string>
|
<string name="notification_action_reply_hint">Введите ответ</string>
|
||||||
<string name="notification_action_mark_read">Прочитано</string>
|
<string name="notification_action_mark_read">Прочитано</string>
|
||||||
|
<string name="notification_messages_count">%1$d сообщений</string>
|
||||||
|
<string name="notification_summary_count">%1$d новых сообщений в %2$d чатах</string>
|
||||||
|
<string name="notification_type_message">Сообщение</string>
|
||||||
|
<string name="notification_type_photo">Фото</string>
|
||||||
|
<string name="notification_type_video">Видео</string>
|
||||||
|
<string name="notification_type_audio">Аудио</string>
|
||||||
|
<string name="notification_type_voice">Голосовое</string>
|
||||||
|
<string name="notification_type_circle">Кружок</string>
|
||||||
|
<string name="notification_type_file">Файл</string>
|
||||||
<string name="profile_avatar_content_description">Аватар</string>
|
<string name="profile_avatar_content_description">Аватар</string>
|
||||||
<string name="profile_user_fallback">Пользователь</string>
|
<string name="profile_user_fallback">Пользователь</string>
|
||||||
<string name="profile_choose_photo">Выбрать фото</string>
|
<string name="profile_choose_photo">Выбрать фото</string>
|
||||||
|
|||||||
@@ -83,6 +83,15 @@
|
|||||||
<string name="notification_action_reply">Reply</string>
|
<string name="notification_action_reply">Reply</string>
|
||||||
<string name="notification_action_reply_hint">Write a reply</string>
|
<string name="notification_action_reply_hint">Write a reply</string>
|
||||||
<string name="notification_action_mark_read">Mark as read</string>
|
<string name="notification_action_mark_read">Mark as read</string>
|
||||||
|
<string name="notification_messages_count">%1$d messages</string>
|
||||||
|
<string name="notification_summary_count">%1$d new messages in %2$d chats</string>
|
||||||
|
<string name="notification_type_message">Message</string>
|
||||||
|
<string name="notification_type_photo">Photo</string>
|
||||||
|
<string name="notification_type_video">Video</string>
|
||||||
|
<string name="notification_type_audio">Audio</string>
|
||||||
|
<string name="notification_type_voice">Voice message</string>
|
||||||
|
<string name="notification_type_circle">Video note</string>
|
||||||
|
<string name="notification_type_file">File</string>
|
||||||
<string name="profile_avatar_content_description">Avatar</string>
|
<string name="profile_avatar_content_description">Avatar</string>
|
||||||
<string name="profile_user_fallback">User</string>
|
<string name="profile_user_fallback">User</string>
|
||||||
<string name="profile_choose_photo">Choose photo</string>
|
<string name="profile_choose_photo">Choose photo</string>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import re
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.chats.repository import is_chat_muted_for_user, list_chat_members
|
from app.chats.repository import is_chat_muted_for_user, list_chat_members
|
||||||
|
from app.media.repository import list_attachments_by_message_ids
|
||||||
from app.messages.models import Message
|
from app.messages.models import Message
|
||||||
from app.notifications.repository import (
|
from app.notifications.repository import (
|
||||||
create_notification_log,
|
create_notification_log,
|
||||||
@@ -29,6 +30,21 @@ def _extract_mentions(text: str | None) -> set[str]:
|
|||||||
return {match.group(1).lower() for match in _MENTION_RE.finditer(text)}
|
return {match.group(1).lower() for match in _MENTION_RE.finditer(text)}
|
||||||
|
|
||||||
|
|
||||||
|
def _message_preview_label(message: Message, image_preview_url: str | None) -> str:
|
||||||
|
text_preview = (message.text or "").strip()
|
||||||
|
if text_preview:
|
||||||
|
return text_preview[:120]
|
||||||
|
message_type = message.type.value if hasattr(message.type, "value") else str(message.type)
|
||||||
|
return {
|
||||||
|
"image": "Photo" if image_preview_url else "Image",
|
||||||
|
"video": "Video",
|
||||||
|
"audio": "Audio",
|
||||||
|
"voice": "Voice message",
|
||||||
|
"circle_video": "Video note",
|
||||||
|
"file": "File",
|
||||||
|
}.get(message_type, "Message")
|
||||||
|
|
||||||
|
|
||||||
async def enqueue_notification(db: AsyncSession, payload: NotificationRequest) -> None:
|
async def enqueue_notification(db: AsyncSession, payload: NotificationRequest) -> None:
|
||||||
await create_notification_log(
|
await create_notification_log(
|
||||||
db,
|
db,
|
||||||
@@ -51,18 +67,28 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) ->
|
|||||||
|
|
||||||
sender_users = await list_users_by_ids(db, [message.sender_id])
|
sender_users = await list_users_by_ids(db, [message.sender_id])
|
||||||
sender_name = sender_users[0].username if sender_users else "Someone"
|
sender_name = sender_users[0].username if sender_users else "Someone"
|
||||||
|
attachments = await list_attachments_by_message_ids(db, message_ids=[message.id])
|
||||||
|
first_attachment = attachments[0] if attachments else None
|
||||||
|
preview_image_url = None
|
||||||
|
if first_attachment and first_attachment.file_type.lower().startswith("image/"):
|
||||||
|
preview_image_url = first_attachment.file_url
|
||||||
|
preview_body = _message_preview_label(message, preview_image_url=preview_image_url)
|
||||||
|
message_type = message.type.value if hasattr(message.type, "value") else str(message.type)
|
||||||
|
|
||||||
for recipient in users:
|
for recipient in users:
|
||||||
base_payload = {
|
base_payload = {
|
||||||
"chat_id": message.chat_id,
|
"chat_id": message.chat_id,
|
||||||
"message_id": message.id,
|
"message_id": message.id,
|
||||||
"sender_id": message.sender_id,
|
"sender_id": message.sender_id,
|
||||||
|
"message_type": message_type,
|
||||||
|
"preview_image_url": preview_image_url or "",
|
||||||
|
"sender_name": sender_name,
|
||||||
}
|
}
|
||||||
if recipient.id in mentioned_user_ids:
|
if recipient.id in mentioned_user_ids:
|
||||||
payload = {
|
payload = {
|
||||||
**base_payload,
|
**base_payload,
|
||||||
"type": "mention",
|
"type": "mention",
|
||||||
"text_preview": (message.text or "")[:120],
|
"text_preview": preview_body,
|
||||||
}
|
}
|
||||||
await create_notification_log(
|
await create_notification_log(
|
||||||
db,
|
db,
|
||||||
@@ -72,8 +98,8 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) ->
|
|||||||
)
|
)
|
||||||
send_mention_notification_task.delay(
|
send_mention_notification_task.delay(
|
||||||
recipient.id,
|
recipient.id,
|
||||||
f"{sender_name} mentioned you",
|
sender_name,
|
||||||
(message.text or "")[:120],
|
preview_body,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -84,7 +110,7 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) ->
|
|||||||
payload = {
|
payload = {
|
||||||
**base_payload,
|
**base_payload,
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"text_preview": (message.text or "")[:120],
|
"text_preview": preview_body,
|
||||||
}
|
}
|
||||||
await create_notification_log(
|
await create_notification_log(
|
||||||
db,
|
db,
|
||||||
@@ -94,8 +120,8 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) ->
|
|||||||
)
|
)
|
||||||
send_push_notification_task.delay(
|
send_push_notification_task.delay(
|
||||||
recipient.id,
|
recipient.id,
|
||||||
f"New message from {sender_name}",
|
sender_name,
|
||||||
(message.text or "")[:120],
|
preview_body,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user