diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt index b7eb873..d480573 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt @@ -4,6 +4,8 @@ package ru.daemonlord.messenger.core.notifications import android.Manifest import android.app.PendingIntent +import android.graphics.BitmapFactory +import android.graphics.Bitmap import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -18,6 +20,7 @@ import ru.daemonlord.messenger.MainActivity import javax.inject.Inject import javax.inject.Singleton import kotlin.math.abs +import java.net.URL @Singleton class NotificationDispatcher @Inject constructor( @@ -32,6 +35,7 @@ class NotificationDispatcher @Inject constructor( } else { NotificationChannels.CHANNEL_MESSAGES } + val displayBody = payload.toDisplayBody(context) val state = synchronized(chatStates) { val existing = chatStates[payload.chatId] @@ -40,7 +44,7 @@ class NotificationDispatcher @Inject constructor( } val updated = (existing ?: ChatNotificationState(title = payload.title)) .copy(title = payload.title) - .appendMessage(payload.body, payload.messageId) + .appendMessage(displayBody, payload.messageId) chatStates[payload.chatId] = updated updated } @@ -82,19 +86,21 @@ class NotificationDispatcher @Inject constructor( ) val contentText = when { - state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body - else -> "${state.unreadCount} new messages" + state.unreadCount <= 1 -> state.lines.firstOrNull() ?: displayBody + else -> context.getString(R.string.notification_messages_count, state.unreadCount) } val inboxStyle = NotificationCompat.InboxStyle() .setBigContentTitle(state.title) - .setSummaryText("${state.unreadCount} messages") + .setSummaryText(context.getString(R.string.notification_messages_count, state.unreadCount)) 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) - .setContentTitle(state.title) + .setContentTitle(payload.senderName?.ifBlank { null } ?: state.title) .setContentText(contentText) - .setStyle(inboxStyle) .setAutoCancel(true) .setContentIntent(pendingIntent) .setGroup(GROUP_KEY_CHATS) @@ -120,7 +126,19 @@ class NotificationDispatcher @Inject constructor( .setAllowGeneratedReplies(true) .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) manager.notifySafely(chatNotificationId(payload.chatId), notification) @@ -145,7 +163,7 @@ class NotificationDispatcher @Inject constructor( val totalUnread = snapshot.sumOf { it.unreadCount } val inboxStyle = NotificationCompat.InboxStyle() .setBigContentTitle("Benya Messenger") - .setSummaryText("$totalUnread messages") + .setSummaryText(context.getString(R.string.notification_messages_count, totalUnread)) snapshot.take(6).forEach { state -> val preview = state.lines.firstOrNull().orEmpty() val line = if (preview.isBlank()) { @@ -158,7 +176,7 @@ class NotificationDispatcher @Inject constructor( val summary = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_MESSAGES) .setSmallIcon(R.drawable.ic_notification_small) .setContentTitle("Benya Messenger") - .setContentText("$totalUnread new messages in ${snapshot.size} chats") + .setContentText(context.getString(R.string.notification_summary_count, totalUnread, snapshot.size)) .setStyle(inboxStyle) .setGroup(GROUP_KEY_CHATS) .setGroupSummary(true) @@ -188,6 +206,35 @@ class NotificationDispatcher @Inject constructor( 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( val title: String, val unreadCount: Int = 0, @@ -195,7 +242,7 @@ class NotificationDispatcher @Inject constructor( val lastMessageId: Long? = null, ) { fun appendMessage(body: String, messageId: Long?): ChatNotificationState { - val normalized = body.trim().ifBlank { "New message" } + val normalized = body.trim().ifBlank { "Message" } val updatedLines = buildList { add(normalized) lines.forEach { add(it) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt index eaad261..ab47251 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt @@ -6,6 +6,9 @@ data class ChatNotificationPayload( val title: String, val body: String, val isMention: Boolean = false, + val messageType: String? = null, + val previewImageUrl: String? = null, + val senderName: String? = null, ) object NotificationIntentExtras { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt b/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt index 69b7bbb..e448e28 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt @@ -38,6 +38,9 @@ object PushPayloadParser { title = title, body = body, isMention = isMention, + messageType = data["message_type"] ?: data["messageType"], + previewImageUrl = data["preview_image_url"] ?: data["previewImageUrl"], + senderName = data["sender_name"] ?: data["senderName"], ) } } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index f48c7a0..76c3636 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -83,6 +83,15 @@ Ответить Введите ответ Прочитано + %1$d сообщений + %1$d новых сообщений в %2$d чатах + Сообщение + Фото + Видео + Аудио + Голосовое + Кружок + Файл Аватар Пользователь Выбрать фото diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9d1e2ab..33c0c7d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -83,6 +83,15 @@ Reply Write a reply Mark as read + %1$d messages + %1$d new messages in %2$d chats + Message + Photo + Video + Audio + Voice message + Video note + File Avatar User Choose photo diff --git a/app/notifications/service.py b/app/notifications/service.py index 1ef8a91..dce6ac2 100644 --- a/app/notifications/service.py +++ b/app/notifications/service.py @@ -4,6 +4,7 @@ import re from sqlalchemy.ext.asyncio import AsyncSession 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.notifications.repository import ( 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)} +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: await create_notification_log( 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_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: base_payload = { "chat_id": message.chat_id, "message_id": message.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: payload = { **base_payload, "type": "mention", - "text_preview": (message.text or "")[:120], + "text_preview": preview_body, } await create_notification_log( db, @@ -72,8 +98,8 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) -> ) send_mention_notification_task.delay( recipient.id, - f"{sender_name} mentioned you", - (message.text or "")[:120], + sender_name, + preview_body, payload, ) continue @@ -84,7 +110,7 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) -> payload = { **base_payload, "type": "message", - "text_preview": (message.text or "")[:120], + "text_preview": preview_body, } await create_notification_log( db, @@ -94,8 +120,8 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) -> ) send_push_notification_task.delay( recipient.id, - f"New message from {sender_name}", - (message.text or "")[:120], + sender_name, + preview_body, payload, )