feat: improve media viewer and push delivery stability
- 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:
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user