feat: improve media viewer and push delivery stability
Some checks failed
Android CI / android (push) Failing after 11m0s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

- add unified Android media viewer with swipe navigation, pinch-to-zoom and swipe-to-dismiss\n- move circle videos out of media gallery and surface them in voice/chat info flows\n- align web chat info handling for circle videos and media viewer exclusions\n- stabilize realtime and tablet chat shell updates already staged in this batch\n- fix Celery push delivery loop handling so FCM jobs can read tokens reliably in worker processes
This commit is contained in:
2026-04-05 14:06:36 +03:00
parent b40dea18f1
commit d2e0969fd5
16 changed files with 1916 additions and 765 deletions

View File

@@ -107,6 +107,8 @@ dependencies {
implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-datasource:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
implementation("androidx.media3:media3-effect:1.4.1")
implementation("androidx.media3:media3-transformer:1.4.1")
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2")

View File

@@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:name=".MessengerApplication"

View File

@@ -20,6 +20,7 @@ import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
import ru.daemonlord.messenger.ui.theme.MessengerTheme
@@ -36,6 +37,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var notificationDispatcher: NotificationDispatcher
@Inject
lateinit var handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase
private var pendingInviteToken by mutableStateOf<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
@@ -70,6 +74,7 @@ class MainActivity : AppCompatActivity() {
pendingNotificationChatId = notificationPayload?.first
pendingNotificationMessageId = notificationPayload?.second
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
handleRealtimeEventsUseCase.start()
enableEdgeToEdge()
setContent {
MessengerTheme {

View File

@@ -1,10 +1,14 @@
package ru.daemonlord.messenger.core.notifications
import android.Manifest
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import ru.daemonlord.messenger.MainActivity
import javax.inject.Inject
@@ -73,7 +77,7 @@ class NotificationDispatcher @Inject constructor(
.build()
val manager = NotificationManagerCompat.from(context)
manager.notify(chatNotificationId(payload.chatId), notification)
manager.notifySafely(chatNotificationId(payload.chatId), notification)
showSummaryNotification(manager)
}
@@ -82,14 +86,14 @@ class NotificationDispatcher @Inject constructor(
synchronized(chatStates) {
chatStates.remove(chatId)
}
manager.cancel(chatNotificationId(chatId))
manager.cancelSafely(chatNotificationId(chatId))
showSummaryNotification(manager)
}
private fun showSummaryNotification(manager: NotificationManagerCompat) {
val snapshot = synchronized(chatStates) { chatStates.values.toList() }
if (snapshot.isEmpty()) {
manager.cancel(SUMMARY_NOTIFICATION_ID)
manager.cancelSafely(SUMMARY_NOTIFICATION_ID)
return
}
val totalUnread = snapshot.sumOf { it.unreadCount }
@@ -114,7 +118,24 @@ class NotificationDispatcher @Inject constructor(
.setGroupSummary(true)
.setAutoCancel(true)
.build()
manager.notify(SUMMARY_NOTIFICATION_ID, summary)
manager.notifySafely(SUMMARY_NOTIFICATION_ID, summary)
}
private fun NotificationManagerCompat.notifySafely(id: Int, notification: android.app.Notification) {
if (!canPostNotifications()) return
runCatching { notify(id, notification) }
}
private fun NotificationManagerCompat.cancelSafely(id: Int) {
runCatching { cancel(id) }
}
private fun canPostNotifications(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
}
private fun chatNotificationId(chatId: Long): Int {

View File

@@ -2,8 +2,8 @@ package ru.daemonlord.messenger.data.media.repository
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Movie
import android.graphics.ImageDecoder
import android.os.Build
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -166,14 +166,8 @@ class NetworkMediaRepository @Inject constructor(
fileName: String,
bytes: ByteArray,
): UploadPayload? {
val movie = runCatching { Movie.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return null
val width = movie.width().coerceAtLeast(1)
val height = movie.height().coerceAtLeast(1)
val bitmap = runCatching { Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) }.getOrNull() ?: return null
val bitmap = decodeGifFirstFrame(bytes) ?: return null
return try {
val canvas = Canvas(bitmap)
movie.setTime(0)
movie.draw(canvas, 0f, 0f)
val output = ByteArrayOutputStream()
val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
if (!compressed) return null
@@ -189,4 +183,17 @@ class NetworkMediaRepository @Inject constructor(
bitmap.recycle()
}
}
private fun decodeGifFirstFrame(bytes: ByteArray): Bitmap? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val decoded = runCatching {
val source = ImageDecoder.createSource(java.nio.ByteBuffer.wrap(bytes))
ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
}
}.getOrNull()
if (decoded != null) return decoded
}
return runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
}
}

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.di
import android.content.Context
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
@@ -15,10 +16,12 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@UnstableApi
object MediaCacheModule {
@Provides
@Singleton
@UnstableApi
fun provideMediaCache(
@ApplicationContext context: Context,
): Cache {
@@ -34,4 +37,3 @@ object MediaCacheModule {
)
}
}

View File

@@ -14,6 +14,7 @@ import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
@@ -27,6 +28,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val chatRepository: ChatRepository,
private val chatDao: ChatDao,
private val messageDao: MessageDao,
private val messageRepository: MessageRepository,
private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker,
private val tokenRepository: TokenRepository,
@@ -50,6 +52,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
is RealtimeEvent.ReceiveMessage -> {
val activeChatId = activeChatTracker.activeChatId.value
val lastMessagePreview = event.text?.takeIf { it.isNotBlank() }
?: fallbackMessagePreview(event.type)
messageDao.upsertMessages(
listOf(
MessageEntity(
@@ -75,16 +79,18 @@ class HandleRealtimeEventsUseCase @Inject constructor(
)
chatDao.updateLastMessage(
chatId = event.chatId,
lastMessageText = event.text,
lastMessageText = lastMessagePreview,
lastMessageType = event.type,
lastMessageCreatedAt = event.createdAt,
updatedSortAt = event.createdAt,
)
if (activeChatId == event.chatId) {
chatDao.markChatRead(chatId = event.chatId)
messageRepository.syncRecentMessages(chatId = event.chatId)
} else {
chatDao.incrementUnread(chatId = event.chatId)
}
chatRepository.refreshChat(chatId = event.chatId)
val activeUserId = tokenRepository.getActiveUserId()
val myUsername = activeUserId?.let { userId ->
tokenRepository.getAccounts()
@@ -142,11 +148,15 @@ class HandleRealtimeEventsUseCase @Inject constructor(
)
chatDao.updateLastMessage(
chatId = event.chatId,
lastMessageText = event.text,
lastMessageText = event.text?.takeIf { it.isNotBlank() } ?: fallbackMessagePreview(event.type),
lastMessageType = event.type,
lastMessageCreatedAt = event.updatedAt,
updatedSortAt = event.updatedAt,
)
if (activeChatTracker.activeChatId.value == event.chatId) {
messageRepository.syncRecentMessages(chatId = event.chatId)
}
chatRepository.refreshChat(chatId = event.chatId)
}
is RealtimeEvent.MessageDeleted -> {
@@ -156,6 +166,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
}
is RealtimeEvent.ChatUpdated -> {
chatRepository.refreshChat(chatId = event.chatId)
chatRepository.refreshChats(archived = false)
chatRepository.refreshChats(archived = true)
}
@@ -209,4 +220,16 @@ class HandleRealtimeEventsUseCase @Inject constructor(
collectionJob = null
realtimeManager.disconnect()
}
private fun fallbackMessagePreview(type: String?): String {
return when (type?.lowercase()) {
"image" -> "Photo"
"video" -> "Video"
"audio" -> "Audio"
"voice" -> "Voice message"
"file" -> "File"
"circle_video" -> "Video message"
else -> "New message"
}
}
}

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.ui.chat.voice
import android.content.Context
import android.os.Build
import android.media.MediaRecorder
import java.io.File
import java.util.UUID
@@ -13,7 +14,7 @@ class VoiceRecorder(private val context: Context) {
fun start(): Boolean {
return runCatching {
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
val mediaRecorder = MediaRecorder().apply {
val mediaRecorder = createMediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
@@ -34,6 +35,15 @@ class VoiceRecorder(private val context: Context) {
}
}
private fun createMediaRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
}
fun elapsedMillis(nowMillis: Long = System.currentTimeMillis()): Long {
if (startedAtMillis <= 0L) return 0L
return (nowMillis - startedAtMillis).coerceAtLeast(0L)

View File

@@ -103,6 +103,7 @@ fun ChatListRoute(
onInviteTokenConsumed: () -> Unit,
isMainBarVisible: Boolean,
onMainBarVisibilityChanged: (Boolean) -> Unit,
selectedChatId: Long? = null,
viewModel: ChatListViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -153,6 +154,7 @@ fun ChatListRoute(
onBanMember = viewModel::banMember,
onUnbanMember = viewModel::unbanMember,
onToggleDayNightMode = viewModel::toggleDayNightMode,
selectedChatId = selectedChatId,
)
}
@@ -194,6 +196,7 @@ fun ChatListScreen(
onBanMember: (Long, Long) -> Unit,
onUnbanMember: (Long, Long) -> Unit,
onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit,
selectedChatId: Long? = null,
) {
val context = LocalContext.current
var managementExpanded by remember { mutableStateOf(false) }
@@ -534,6 +537,7 @@ fun ChatListScreen(
chat = chat,
isSelecting = selectedChatIds.isNotEmpty(),
isSelected = selectedChatIds.contains(chat.id),
isActive = selectedChatIds.isEmpty() && selectedChatId == chat.id,
onClick = {
if (selectedChatIds.isNotEmpty()) {
selectedChatIds = if (selectedChatIds.contains(chat.id)) {
@@ -1392,12 +1396,20 @@ private fun ChatRow(
chat: ChatItem,
isSelecting: Boolean,
isSelected: Boolean,
isActive: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(
when {
isActive -> MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else -> Color.Transparent
},
)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,

View File

@@ -3,17 +3,21 @@ package ru.daemonlord.messenger.ui.navigation
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -28,6 +32,7 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -37,10 +42,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import androidx.compose.ui.Alignment
@@ -99,16 +107,23 @@ fun MessengerNavHost(
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val configuration = LocalConfiguration.current
val isTabletLandscape = remember(configuration.screenWidthDp, configuration.screenHeightDp) {
configuration.screenWidthDp >= 840 && configuration.screenWidthDp > configuration.screenHeightDp
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) {}
var isMainBarVisible by remember { mutableStateOf(true) }
var tabletSelectedChatId by rememberSaveable { mutableStateOf<Long?>(null) }
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val mainTabRoutes = remember {
setOf(Routes.Chats, Routes.Contacts, Routes.Settings, Routes.Profile)
}
val showMainBar = currentRoute in mainTabRoutes
val isChatRoute = currentRoute?.startsWith("${Routes.Chat}/") == true
val showMainBar = (currentRoute in mainTabRoutes) &&
!(isTabletLandscape && (currentRoute == Routes.Chats || isChatRoute))
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
@@ -143,11 +158,21 @@ fun MessengerNavHost(
}
if (uiState.isAuthenticated) {
if (notificationChatId != null) {
navController.navigate("${Routes.Chat}/$notificationChatId") {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
if (isTabletLandscape) {
tabletSelectedChatId = notificationChatId
navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
launchSingleTop = true
}
} else {
navController.navigate("${Routes.Chat}/$notificationChatId") {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
launchSingleTop = true
}
launchSingleTop = true
}
onNotificationConsumed()
} else {
@@ -277,15 +302,25 @@ fun MessengerNavHost(
}
composable(route = Routes.Chats) {
ChatListRoute(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
onOpenChat = { chatId ->
navController.navigate("${Routes.Chat}/$chatId")
},
isMainBarVisible = isMainBarVisible,
onMainBarVisibilityChanged = { isMainBarVisible = it },
)
if (isTabletLandscape) {
TabletChatsRoute(
parentNavController = navController,
selectedChatId = tabletSelectedChatId,
onSelectChat = { tabletSelectedChatId = it },
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
)
} else {
ChatListRoute(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
onOpenChat = { chatId ->
navController.navigate("${Routes.Chat}/$chatId")
},
isMainBarVisible = isMainBarVisible,
onMainBarVisibilityChanged = { isMainBarVisible = it },
)
}
}
composable(route = Routes.Contacts) {
@@ -323,9 +358,30 @@ fun MessengerNavHost(
navArgument("chatId") { type = NavType.LongType }
),
) { backStackEntry ->
ChatRoute(
onBack = { navController.popBackStack() },
)
val selectedChatId = backStackEntry.arguments?.getLong("chatId")
if (isTabletLandscape) {
LaunchedEffect(selectedChatId) {
tabletSelectedChatId = selectedChatId
navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
TabletChatsRoute(
parentNavController = navController,
selectedChatId = tabletSelectedChatId ?: selectedChatId,
onSelectChat = { tabletSelectedChatId = it },
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
)
} else {
ChatRoute(
onBack = { navController.popBackStack() },
)
}
}
}
@@ -355,14 +411,116 @@ fun MessengerNavHost(
}
}
@Composable
private fun TabletChatsRoute(
parentNavController: NavHostController,
selectedChatId: Long?,
onSelectChat: (Long?) -> Unit,
inviteToken: String?,
onInviteTokenConsumed: () -> Unit,
) {
val configuration = LocalConfiguration.current
val listPaneWidth = remember(configuration.screenWidthDp) {
(configuration.screenWidthDp * 0.29f).dp.coerceIn(320.dp, 420.dp)
}
BackHandler(enabled = selectedChatId != null) {
onSelectChat(null)
}
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
Surface(
modifier = Modifier
.width(listPaneWidth)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
) {
Box(modifier = Modifier.fillMaxSize()) {
ChatListRoute(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
onOpenChat = { chatId ->
onSelectChat(chatId)
},
isMainBarVisible = true,
onMainBarVisibilityChanged = {},
selectedChatId = selectedChatId,
)
MainBottomBar(
currentRoute = Routes.Chats,
onNavigate = { route ->
if (route == Routes.Chats) {
parentNavController.navigate(Routes.Chats) {
popUpTo(parentNavController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
} else {
parentNavController.navigate(route) {
popUpTo(parentNavController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(horizontal = 12.dp, vertical = 12.dp),
)
}
}
Surface(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp,
) {
key(selectedChatId) {
val detailNavController = rememberNavController()
NavHost(
navController = detailNavController,
startDestination = if (selectedChatId == null) "empty" else "${Routes.Chat}/$selectedChatId",
) {
composable("empty") {
TabletChatPlaceholder()
}
composable(
route = "${Routes.Chat}/{chatId}",
arguments = listOf(navArgument("chatId") { type = NavType.LongType }),
) {
ChatRoute(
onBack = {
onSelectChat(null)
},
showBackButton = false,
)
}
}
}
}
}
}
@Composable
private fun MainBottomBar(
currentRoute: String?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier.padding(horizontal = 12.dp),
) {
Surface(
modifier = Modifier
.padding(horizontal = 12.dp)
modifier = modifier
.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
@@ -454,3 +612,58 @@ private fun SessionCheckingScreen() {
CircularProgressIndicator()
}
}
@Composable
private fun TabletChatPlaceholder() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f),
tonalElevation = 2.dp,
shadowElevation = 10.dp,
modifier = Modifier.fillMaxWidth(0.56f),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
modifier = Modifier.padding(bottom = 18.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Chat,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(18.dp),
)
}
Text(
text = stringResource(id = R.string.tablet_chat_placeholder_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(id = R.string.tablet_chat_placeholder_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
Text(
text = stringResource(id = R.string.tablet_chat_placeholder_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f),
modifier = Modifier.padding(top = 16.dp),
)
}
}
}
}

View File

@@ -28,6 +28,9 @@
<string name="chats_dialog_delete_selected_title">Удалить выбранные чаты</string>
<string name="chats_dialog_delete_selected_body">Вы уверены, что хотите удалить выбранные чаты?</string>
<string name="chats_dialog_delete_for_all">Удалить для всех (где доступно)</string>
<string name="tablet_chat_placeholder_title">Выберите чат</string>
<string name="tablet_chat_placeholder_body">Откройте диалог из левой колонки, и он появится здесь.</string>
<string name="tablet_chat_placeholder_hint">Открытый чат остаётся справа, а список слева всегда под рукой.</string>
<string name="filter_all">Все</string>
<string name="filter_people">Люди</string>
@@ -158,6 +161,7 @@
<string name="chat_picker_tab_stickers">Стикеры</string>
<string name="chat_playback_subtitle_voice">Голосовое сообщение • %1$s</string>
<string name="chat_playback_subtitle_audio">Аудио • %1$s</string>
<string name="chat_playback_subtitle_circle">Видеокружок • %1$s</string>
<string name="chat_user_fallback_with_id">Пользователь #%1$d</string>
<string name="chat_member_role_with_id">%1$s • id %2$d</string>
<string name="chat_member_id">id %1$d</string>
@@ -332,4 +336,5 @@
<string name="account_info_recovery_codes_regenerated">Коды восстановления перегенерированы.</string>
<string name="account_error_invalid_credentials">Неверные учетные данные.</string>
<string name="account_error_unauthorized">Не авторизовано.</string>
<string name="chat_audio_strip_video_note">Видеокружок</string>
</resources>

View File

@@ -68,6 +68,9 @@
<string name="chats_dialog_delete_selected_title">Delete selected chats</string>
<string name="chats_dialog_delete_selected_body">Are you sure you want to delete selected chats?</string>
<string name="chats_dialog_delete_for_all">Delete for all (where allowed)</string>
<string name="tablet_chat_placeholder_title">Choose a chat</string>
<string name="tablet_chat_placeholder_body">Select a conversation from the left column to open it here.</string>
<string name="tablet_chat_placeholder_hint">Your current chat stays open here while the list remains visible.</string>
<string name="common_cancel">Cancel</string>
<string name="common_confirm">Confirm</string>
@@ -158,6 +161,7 @@
<string name="chat_picker_tab_stickers">Stickers</string>
<string name="chat_playback_subtitle_voice">Voice message • %1$s</string>
<string name="chat_playback_subtitle_audio">Audio • %1$s</string>
<string name="chat_playback_subtitle_circle">Video note • %1$s</string>
<string name="chat_user_fallback_with_id">User #%1$d</string>
<string name="chat_member_role_with_id">%1$s • id %2$d</string>
<string name="chat_member_id">id %1$d</string>
@@ -332,4 +336,5 @@
<string name="account_info_recovery_codes_regenerated">Recovery codes regenerated.</string>
<string name="account_error_invalid_credentials">Invalid credentials.</string>
<string name="account_error_unauthorized">Unauthorized.</string>
<string name="chat_audio_strip_video_note">Video note</string>
</resources>

View File

@@ -15,6 +15,19 @@ logger = logging.getLogger(__name__)
_firebase_app: firebase_admin.App | None = None
_worker_loop: asyncio.AbstractEventLoop | None = None
def _get_worker_loop() -> asyncio.AbstractEventLoop:
global _worker_loop
if _worker_loop is None or _worker_loop.is_closed():
_worker_loop = asyncio.new_event_loop()
return _worker_loop
def _run_async(coro):
loop = _get_worker_loop()
return loop.run_until_complete(coro)
def _get_firebase_app() -> firebase_admin.App | None:
@@ -63,7 +76,7 @@ def _send_fcm_to_user(user_id: int, title: str, body: str, data: dict[str, Any])
logger.info("Skipping FCM send for user=%s: Firebase disabled", user_id)
return
tokens = asyncio.run(_load_tokens(user_id))
tokens = _run_async(_load_tokens(user_id))
if not tokens:
return
@@ -83,9 +96,9 @@ def _send_fcm_to_user(user_id: int, title: str, body: str, data: dict[str, Any])
try:
messaging.send(message, app=app)
except messaging.UnregisteredError:
asyncio.run(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
_run_async(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
except messaging.SenderIdMismatchError:
asyncio.run(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
_run_async(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
except Exception:
logger.exception("FCM send failed for user=%s platform=%s", user_id, platform)

View File

@@ -104,9 +104,21 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const count = chat.members_count ?? members.length;
return count <= 1;
}, [chat, isGroupLike, myRoleNormalized, members.length]);
const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id), [attachments]);
const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")).sort((a, b) => b.id - a.id), [attachments]);
const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice").sort((a, b) => b.id - a.id), [attachments]);
const photoAttachments = useMemo(
() => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id),
[attachments]
);
const videoAttachments = useMemo(
() =>
attachments
.filter((item) => item.file_type.startsWith("video/") && item.message_type !== "circle_video")
.sort((a, b) => b.id - a.id),
[attachments]
);
const voiceAttachments = useMemo(
() => attachments.filter((item) => item.message_type === "voice" || item.message_type === "circle_video").sort((a, b) => b.id - a.id),
[attachments]
);
const audioAttachments = useMemo(
() =>
attachments
@@ -871,10 +883,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
.slice(0, 120)
.map((item) => (
<button
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
className={`group relative aspect-square overflow-hidden border border-slate-700/70 bg-slate-900 ${item.message_type === "circle_video" ? "rounded-full" : "rounded-md"}`}
key={`media-item-${item.id}`}
onClick={() => {
if (item.message_type === "circle_video") {
jumpToMessage(item.message_id);
return;
}
const mediaItems = [...photoAttachments, ...videoAttachments]
.filter((it) => it.message_type !== "circle_video")
.sort((a, b) => b.id - a.id)
.map((it) => ({ url: it.file_url, type: it.file_type.startsWith("video/") ? "video" as const : "image" as const, messageId: it.message_id }));
const idx = mediaItems.findIndex((it) => it.url === item.file_url && it.messageId === item.message_id);

View File

@@ -1100,6 +1100,7 @@ function renderMessageContent(
if (messageType === "image" || messageType === "video" || messageType === "circle_video") {
const mediaItems = attachments
.filter((item) => item.message_type !== "circle_video")
.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/"))
.map((item) => ({
url: item.file_url,
@@ -1113,13 +1114,14 @@ function renderMessageContent(
}
if (mediaItems.length === 1) {
const item = mediaItems[0];
const isCircleVideo = messageType === "circle_video";
const blockViewerOpen = isStickerOrGifMedia(item.url);
return (
<div className="space-y-1.5">
<button
className="relative block overflow-hidden rounded-xl bg-slate-950/30"
className={`relative block overflow-hidden bg-slate-950/30 ${isCircleVideo ? "aspect-square w-56 rounded-full" : "rounded-xl"}`}
onClick={() => {
if (blockViewerOpen) {
if (blockViewerOpen || isCircleVideo) {
return;
}
opts.onOpenMedia(item.url, item.type);
@@ -1131,10 +1133,15 @@ function renderMessageContent(
type="button"
>
{item.type === "image" ? (
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={item.url} />
<img
alt="attachment"
className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl object-cover"}
draggable={false}
src={item.url}
/>
) : (
<>
<video className="max-h-80 rounded-xl" muted src={item.url} />
<video className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl"} muted src={item.url} />
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85"></span>
</>
)}
@@ -1410,6 +1417,7 @@ function collectMediaItems(
const attachments = attachmentsByMessage[message.id] ?? [];
for (const attachment of attachments) {
if (!attachment.file_url) continue;
if (attachment.message_type === "circle_video") continue;
if (!attachment.file_type.startsWith("image/") && !attachment.file_type.startsWith("video/")) continue;
if (isStickerOrGifMedia(attachment.file_url)) continue;
const type = attachment.file_type.startsWith("image/") ? "image" : "video";
@@ -1419,7 +1427,7 @@ function collectMediaItems(
items.push({ url: attachment.file_url, type });
}
if (attachments.length === 0 && message.text && /^https?:\/\//i.test(message.text)) {
if (message.type !== "image" && message.type !== "video" && message.type !== "circle_video") continue;
if (message.type !== "image" && message.type !== "video") continue;
if (isStickerOrGifMedia(message.text)) continue;
const type = message.type === "image" ? "image" : "video";
const key = `${type}:${message.text}`;