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-ui:1.4.1")
|
||||||
implementation("androidx.media3:media3-datasource:1.4.1")
|
implementation("androidx.media3:media3-datasource:1.4.1")
|
||||||
implementation("androidx.media3:media3-datasource-okhttp: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-core:1.4.2")
|
||||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||||
implementation("androidx.camera:camera-lifecycle: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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MessengerApplication"
|
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.core.notifications.NotificationIntentExtras
|
||||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
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.domain.settings.repository.ThemeRepository
|
||||||
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
|
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
|
||||||
import ru.daemonlord.messenger.ui.theme.MessengerTheme
|
import ru.daemonlord.messenger.ui.theme.MessengerTheme
|
||||||
@@ -36,6 +37,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var notificationDispatcher: NotificationDispatcher
|
lateinit var notificationDispatcher: NotificationDispatcher
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase
|
||||||
|
|
||||||
private var pendingInviteToken by mutableStateOf<String?>(null)
|
private var pendingInviteToken by mutableStateOf<String?>(null)
|
||||||
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
|
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
|
||||||
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
|
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
|
||||||
@@ -70,6 +74,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
pendingNotificationChatId = notificationPayload?.first
|
pendingNotificationChatId = notificationPayload?.first
|
||||||
pendingNotificationMessageId = notificationPayload?.second
|
pendingNotificationMessageId = notificationPayload?.second
|
||||||
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
|
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
|
||||||
|
handleRealtimeEventsUseCase.start()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
MessengerTheme {
|
MessengerTheme {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package ru.daemonlord.messenger.core.notifications
|
package ru.daemonlord.messenger.core.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import ru.daemonlord.messenger.MainActivity
|
import ru.daemonlord.messenger.MainActivity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -73,7 +77,7 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
val manager = NotificationManagerCompat.from(context)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
manager.notify(chatNotificationId(payload.chatId), notification)
|
manager.notifySafely(chatNotificationId(payload.chatId), notification)
|
||||||
showSummaryNotification(manager)
|
showSummaryNotification(manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,14 +86,14 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
synchronized(chatStates) {
|
synchronized(chatStates) {
|
||||||
chatStates.remove(chatId)
|
chatStates.remove(chatId)
|
||||||
}
|
}
|
||||||
manager.cancel(chatNotificationId(chatId))
|
manager.cancelSafely(chatNotificationId(chatId))
|
||||||
showSummaryNotification(manager)
|
showSummaryNotification(manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showSummaryNotification(manager: NotificationManagerCompat) {
|
private fun showSummaryNotification(manager: NotificationManagerCompat) {
|
||||||
val snapshot = synchronized(chatStates) { chatStates.values.toList() }
|
val snapshot = synchronized(chatStates) { chatStates.values.toList() }
|
||||||
if (snapshot.isEmpty()) {
|
if (snapshot.isEmpty()) {
|
||||||
manager.cancel(SUMMARY_NOTIFICATION_ID)
|
manager.cancelSafely(SUMMARY_NOTIFICATION_ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val totalUnread = snapshot.sumOf { it.unreadCount }
|
val totalUnread = snapshot.sumOf { it.unreadCount }
|
||||||
@@ -114,7 +118,24 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
.setGroupSummary(true)
|
.setGroupSummary(true)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.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 {
|
private fun chatNotificationId(chatId: Long): Int {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package ru.daemonlord.messenger.data.media.repository
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Canvas
|
import android.graphics.ImageDecoder
|
||||||
import android.graphics.Movie
|
import android.os.Build
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
@@ -166,14 +166,8 @@ class NetworkMediaRepository @Inject constructor(
|
|||||||
fileName: String,
|
fileName: String,
|
||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
): UploadPayload? {
|
): UploadPayload? {
|
||||||
val movie = runCatching { Movie.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return null
|
val bitmap = decodeGifFirstFrame(bytes) ?: 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
|
|
||||||
return try {
|
return try {
|
||||||
val canvas = Canvas(bitmap)
|
|
||||||
movie.setTime(0)
|
|
||||||
movie.draw(canvas, 0f, 0f)
|
|
||||||
val output = ByteArrayOutputStream()
|
val output = ByteArrayOutputStream()
|
||||||
val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||||
if (!compressed) return null
|
if (!compressed) return null
|
||||||
@@ -189,4 +183,17 @@ class NetworkMediaRepository @Inject constructor(
|
|||||||
bitmap.recycle()
|
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
|
package ru.daemonlord.messenger.di
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.database.StandaloneDatabaseProvider
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
import androidx.media3.datasource.cache.Cache
|
import androidx.media3.datasource.cache.Cache
|
||||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
@@ -15,10 +16,12 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
|
@UnstableApi
|
||||||
object MediaCacheModule {
|
object MediaCacheModule {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
@UnstableApi
|
||||||
fun provideMediaCache(
|
fun provideMediaCache(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): Cache {
|
): 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.dao.MessageDao
|
||||||
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
||||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
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.repository.NotificationSettingsRepository
|
||||||
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
|
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
|
||||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||||
@@ -27,6 +28,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
|||||||
private val chatRepository: ChatRepository,
|
private val chatRepository: ChatRepository,
|
||||||
private val chatDao: ChatDao,
|
private val chatDao: ChatDao,
|
||||||
private val messageDao: MessageDao,
|
private val messageDao: MessageDao,
|
||||||
|
private val messageRepository: MessageRepository,
|
||||||
private val notificationDispatcher: NotificationDispatcher,
|
private val notificationDispatcher: NotificationDispatcher,
|
||||||
private val activeChatTracker: ActiveChatTracker,
|
private val activeChatTracker: ActiveChatTracker,
|
||||||
private val tokenRepository: TokenRepository,
|
private val tokenRepository: TokenRepository,
|
||||||
@@ -50,6 +52,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
|||||||
|
|
||||||
is RealtimeEvent.ReceiveMessage -> {
|
is RealtimeEvent.ReceiveMessage -> {
|
||||||
val activeChatId = activeChatTracker.activeChatId.value
|
val activeChatId = activeChatTracker.activeChatId.value
|
||||||
|
val lastMessagePreview = event.text?.takeIf { it.isNotBlank() }
|
||||||
|
?: fallbackMessagePreview(event.type)
|
||||||
messageDao.upsertMessages(
|
messageDao.upsertMessages(
|
||||||
listOf(
|
listOf(
|
||||||
MessageEntity(
|
MessageEntity(
|
||||||
@@ -75,16 +79,18 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
|||||||
)
|
)
|
||||||
chatDao.updateLastMessage(
|
chatDao.updateLastMessage(
|
||||||
chatId = event.chatId,
|
chatId = event.chatId,
|
||||||
lastMessageText = event.text,
|
lastMessageText = lastMessagePreview,
|
||||||
lastMessageType = event.type,
|
lastMessageType = event.type,
|
||||||
lastMessageCreatedAt = event.createdAt,
|
lastMessageCreatedAt = event.createdAt,
|
||||||
updatedSortAt = event.createdAt,
|
updatedSortAt = event.createdAt,
|
||||||
)
|
)
|
||||||
if (activeChatId == event.chatId) {
|
if (activeChatId == event.chatId) {
|
||||||
chatDao.markChatRead(chatId = event.chatId)
|
chatDao.markChatRead(chatId = event.chatId)
|
||||||
|
messageRepository.syncRecentMessages(chatId = event.chatId)
|
||||||
} else {
|
} else {
|
||||||
chatDao.incrementUnread(chatId = event.chatId)
|
chatDao.incrementUnread(chatId = event.chatId)
|
||||||
}
|
}
|
||||||
|
chatRepository.refreshChat(chatId = event.chatId)
|
||||||
val activeUserId = tokenRepository.getActiveUserId()
|
val activeUserId = tokenRepository.getActiveUserId()
|
||||||
val myUsername = activeUserId?.let { userId ->
|
val myUsername = activeUserId?.let { userId ->
|
||||||
tokenRepository.getAccounts()
|
tokenRepository.getAccounts()
|
||||||
@@ -142,11 +148,15 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
|||||||
)
|
)
|
||||||
chatDao.updateLastMessage(
|
chatDao.updateLastMessage(
|
||||||
chatId = event.chatId,
|
chatId = event.chatId,
|
||||||
lastMessageText = event.text,
|
lastMessageText = event.text?.takeIf { it.isNotBlank() } ?: fallbackMessagePreview(event.type),
|
||||||
lastMessageType = event.type,
|
lastMessageType = event.type,
|
||||||
lastMessageCreatedAt = event.updatedAt,
|
lastMessageCreatedAt = event.updatedAt,
|
||||||
updatedSortAt = event.updatedAt,
|
updatedSortAt = event.updatedAt,
|
||||||
)
|
)
|
||||||
|
if (activeChatTracker.activeChatId.value == event.chatId) {
|
||||||
|
messageRepository.syncRecentMessages(chatId = event.chatId)
|
||||||
|
}
|
||||||
|
chatRepository.refreshChat(chatId = event.chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
is RealtimeEvent.MessageDeleted -> {
|
is RealtimeEvent.MessageDeleted -> {
|
||||||
@@ -156,6 +166,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is RealtimeEvent.ChatUpdated -> {
|
is RealtimeEvent.ChatUpdated -> {
|
||||||
|
chatRepository.refreshChat(chatId = event.chatId)
|
||||||
chatRepository.refreshChats(archived = false)
|
chatRepository.refreshChats(archived = false)
|
||||||
chatRepository.refreshChats(archived = true)
|
chatRepository.refreshChats(archived = true)
|
||||||
}
|
}
|
||||||
@@ -209,4 +220,16 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
|||||||
collectionJob = null
|
collectionJob = null
|
||||||
realtimeManager.disconnect()
|
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
|
package ru.daemonlord.messenger.ui.chat.voice
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -13,7 +14,7 @@ class VoiceRecorder(private val context: Context) {
|
|||||||
fun start(): Boolean {
|
fun start(): Boolean {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
|
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
|
||||||
val mediaRecorder = MediaRecorder().apply {
|
val mediaRecorder = createMediaRecorder().apply {
|
||||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
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 {
|
fun elapsedMillis(nowMillis: Long = System.currentTimeMillis()): Long {
|
||||||
if (startedAtMillis <= 0L) return 0L
|
if (startedAtMillis <= 0L) return 0L
|
||||||
return (nowMillis - startedAtMillis).coerceAtLeast(0L)
|
return (nowMillis - startedAtMillis).coerceAtLeast(0L)
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ fun ChatListRoute(
|
|||||||
onInviteTokenConsumed: () -> Unit,
|
onInviteTokenConsumed: () -> Unit,
|
||||||
isMainBarVisible: Boolean,
|
isMainBarVisible: Boolean,
|
||||||
onMainBarVisibilityChanged: (Boolean) -> Unit,
|
onMainBarVisibilityChanged: (Boolean) -> Unit,
|
||||||
|
selectedChatId: Long? = null,
|
||||||
viewModel: ChatListViewModel = hiltViewModel(),
|
viewModel: ChatListViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -153,6 +154,7 @@ fun ChatListRoute(
|
|||||||
onBanMember = viewModel::banMember,
|
onBanMember = viewModel::banMember,
|
||||||
onUnbanMember = viewModel::unbanMember,
|
onUnbanMember = viewModel::unbanMember,
|
||||||
onToggleDayNightMode = viewModel::toggleDayNightMode,
|
onToggleDayNightMode = viewModel::toggleDayNightMode,
|
||||||
|
selectedChatId = selectedChatId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +196,7 @@ fun ChatListScreen(
|
|||||||
onBanMember: (Long, Long) -> Unit,
|
onBanMember: (Long, Long) -> Unit,
|
||||||
onUnbanMember: (Long, Long) -> Unit,
|
onUnbanMember: (Long, Long) -> Unit,
|
||||||
onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit,
|
onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit,
|
||||||
|
selectedChatId: Long? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var managementExpanded by remember { mutableStateOf(false) }
|
var managementExpanded by remember { mutableStateOf(false) }
|
||||||
@@ -534,6 +537,7 @@ fun ChatListScreen(
|
|||||||
chat = chat,
|
chat = chat,
|
||||||
isSelecting = selectedChatIds.isNotEmpty(),
|
isSelecting = selectedChatIds.isNotEmpty(),
|
||||||
isSelected = selectedChatIds.contains(chat.id),
|
isSelected = selectedChatIds.contains(chat.id),
|
||||||
|
isActive = selectedChatIds.isEmpty() && selectedChatId == chat.id,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (selectedChatIds.isNotEmpty()) {
|
if (selectedChatIds.isNotEmpty()) {
|
||||||
selectedChatIds = if (selectedChatIds.contains(chat.id)) {
|
selectedChatIds = if (selectedChatIds.contains(chat.id)) {
|
||||||
@@ -1392,12 +1396,20 @@ private fun ChatRow(
|
|||||||
chat: ChatItem,
|
chat: ChatItem,
|
||||||
isSelecting: Boolean,
|
isSelecting: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
|
isActive: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(
|
||||||
|
when {
|
||||||
|
isActive -> MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
|
else -> Color.Transparent
|
||||||
|
},
|
||||||
|
)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ package ru.daemonlord.messenger.ui.navigation
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -28,6 +32,7 @@ import androidx.compose.material3.NavigationBar
|
|||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.NavigationBarItemDefaults
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -37,10 +42,13 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -99,16 +107,23 @@ fun MessengerNavHost(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val context = LocalContext.current
|
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(
|
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) {}
|
) {}
|
||||||
var isMainBarVisible by remember { mutableStateOf(true) }
|
var isMainBarVisible by remember { mutableStateOf(true) }
|
||||||
|
var tabletSelectedChatId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = backStackEntry?.destination?.route
|
val currentRoute = backStackEntry?.destination?.route
|
||||||
val mainTabRoutes = remember {
|
val mainTabRoutes = remember {
|
||||||
setOf(Routes.Chats, Routes.Contacts, Routes.Settings, Routes.Profile)
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
|
||||||
@@ -143,11 +158,21 @@ fun MessengerNavHost(
|
|||||||
}
|
}
|
||||||
if (uiState.isAuthenticated) {
|
if (uiState.isAuthenticated) {
|
||||||
if (notificationChatId != null) {
|
if (notificationChatId != null) {
|
||||||
navController.navigate("${Routes.Chat}/$notificationChatId") {
|
if (isTabletLandscape) {
|
||||||
popUpTo(navController.graph.findStartDestination().id) {
|
tabletSelectedChatId = notificationChatId
|
||||||
inclusive = true
|
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()
|
onNotificationConsumed()
|
||||||
} else {
|
} else {
|
||||||
@@ -277,15 +302,25 @@ fun MessengerNavHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable(route = Routes.Chats) {
|
composable(route = Routes.Chats) {
|
||||||
ChatListRoute(
|
if (isTabletLandscape) {
|
||||||
inviteToken = inviteToken,
|
TabletChatsRoute(
|
||||||
onInviteTokenConsumed = onInviteTokenConsumed,
|
parentNavController = navController,
|
||||||
onOpenChat = { chatId ->
|
selectedChatId = tabletSelectedChatId,
|
||||||
navController.navigate("${Routes.Chat}/$chatId")
|
onSelectChat = { tabletSelectedChatId = it },
|
||||||
},
|
inviteToken = inviteToken,
|
||||||
isMainBarVisible = isMainBarVisible,
|
onInviteTokenConsumed = onInviteTokenConsumed,
|
||||||
onMainBarVisibilityChanged = { isMainBarVisible = it },
|
)
|
||||||
)
|
} else {
|
||||||
|
ChatListRoute(
|
||||||
|
inviteToken = inviteToken,
|
||||||
|
onInviteTokenConsumed = onInviteTokenConsumed,
|
||||||
|
onOpenChat = { chatId ->
|
||||||
|
navController.navigate("${Routes.Chat}/$chatId")
|
||||||
|
},
|
||||||
|
isMainBarVisible = isMainBarVisible,
|
||||||
|
onMainBarVisibilityChanged = { isMainBarVisible = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(route = Routes.Contacts) {
|
composable(route = Routes.Contacts) {
|
||||||
@@ -323,9 +358,30 @@ fun MessengerNavHost(
|
|||||||
navArgument("chatId") { type = NavType.LongType }
|
navArgument("chatId") { type = NavType.LongType }
|
||||||
),
|
),
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
ChatRoute(
|
val selectedChatId = backStackEntry.arguments?.getLong("chatId")
|
||||||
onBack = { navController.popBackStack() },
|
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
|
@Composable
|
||||||
private fun MainBottomBar(
|
private fun MainBottomBar(
|
||||||
currentRoute: String?,
|
currentRoute: String?,
|
||||||
onNavigate: (String) -> Unit,
|
onNavigate: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(28.dp),
|
shape = RoundedCornerShape(28.dp),
|
||||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
|
||||||
@@ -454,3 +612,58 @@ private fun SessionCheckingScreen() {
|
|||||||
CircularProgressIndicator()
|
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_title">Удалить выбранные чаты</string>
|
||||||
<string name="chats_dialog_delete_selected_body">Вы уверены, что хотите удалить выбранные чаты?</string>
|
<string name="chats_dialog_delete_selected_body">Вы уверены, что хотите удалить выбранные чаты?</string>
|
||||||
<string name="chats_dialog_delete_for_all">Удалить для всех (где доступно)</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_all">Все</string>
|
||||||
<string name="filter_people">Люди</string>
|
<string name="filter_people">Люди</string>
|
||||||
@@ -158,6 +161,7 @@
|
|||||||
<string name="chat_picker_tab_stickers">Стикеры</string>
|
<string name="chat_picker_tab_stickers">Стикеры</string>
|
||||||
<string name="chat_playback_subtitle_voice">Голосовое сообщение • %1$s</string>
|
<string name="chat_playback_subtitle_voice">Голосовое сообщение • %1$s</string>
|
||||||
<string name="chat_playback_subtitle_audio">Аудио • %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_user_fallback_with_id">Пользователь #%1$d</string>
|
||||||
<string name="chat_member_role_with_id">%1$s • id %2$d</string>
|
<string name="chat_member_role_with_id">%1$s • id %2$d</string>
|
||||||
<string name="chat_member_id">id %1$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_info_recovery_codes_regenerated">Коды восстановления перегенерированы.</string>
|
||||||
<string name="account_error_invalid_credentials">Неверные учетные данные.</string>
|
<string name="account_error_invalid_credentials">Неверные учетные данные.</string>
|
||||||
<string name="account_error_unauthorized">Не авторизовано.</string>
|
<string name="account_error_unauthorized">Не авторизовано.</string>
|
||||||
|
<string name="chat_audio_strip_video_note">Видеокружок</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -68,6 +68,9 @@
|
|||||||
<string name="chats_dialog_delete_selected_title">Delete selected chats</string>
|
<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_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="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_cancel">Cancel</string>
|
||||||
<string name="common_confirm">Confirm</string>
|
<string name="common_confirm">Confirm</string>
|
||||||
@@ -158,6 +161,7 @@
|
|||||||
<string name="chat_picker_tab_stickers">Stickers</string>
|
<string name="chat_picker_tab_stickers">Stickers</string>
|
||||||
<string name="chat_playback_subtitle_voice">Voice message • %1$s</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_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_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_role_with_id">%1$s • id %2$d</string>
|
||||||
<string name="chat_member_id">id %1$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_info_recovery_codes_regenerated">Recovery codes regenerated.</string>
|
||||||
<string name="account_error_invalid_credentials">Invalid credentials.</string>
|
<string name="account_error_invalid_credentials">Invalid credentials.</string>
|
||||||
<string name="account_error_unauthorized">Unauthorized.</string>
|
<string name="account_error_unauthorized">Unauthorized.</string>
|
||||||
|
<string name="chat_audio_strip_video_note">Video note</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
_firebase_app: firebase_admin.App | None = None
|
_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:
|
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)
|
logger.info("Skipping FCM send for user=%s: Firebase disabled", user_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
tokens = asyncio.run(_load_tokens(user_id))
|
tokens = _run_async(_load_tokens(user_id))
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -83,9 +96,9 @@ def _send_fcm_to_user(user_id: int, title: str, body: str, data: dict[str, Any])
|
|||||||
try:
|
try:
|
||||||
messaging.send(message, app=app)
|
messaging.send(message, app=app)
|
||||||
except messaging.UnregisteredError:
|
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:
|
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:
|
except Exception:
|
||||||
logger.exception("FCM send failed for user=%s platform=%s", user_id, platform)
|
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;
|
const count = chat.members_count ?? members.length;
|
||||||
return count <= 1;
|
return count <= 1;
|
||||||
}, [chat, isGroupLike, myRoleNormalized, members.length]);
|
}, [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 photoAttachments = useMemo(
|
||||||
const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")).sort((a, b) => b.id - a.id), [attachments]);
|
() => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id),
|
||||||
const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice").sort((a, b) => b.id - a.id), [attachments]);
|
[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(
|
const audioAttachments = useMemo(
|
||||||
() =>
|
() =>
|
||||||
attachments
|
attachments
|
||||||
@@ -871,10 +883,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
|||||||
.slice(0, 120)
|
.slice(0, 120)
|
||||||
.map((item) => (
|
.map((item) => (
|
||||||
<button
|
<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}`}
|
key={`media-item-${item.id}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (item.message_type === "circle_video") {
|
||||||
|
jumpToMessage(item.message_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mediaItems = [...photoAttachments, ...videoAttachments]
|
const mediaItems = [...photoAttachments, ...videoAttachments]
|
||||||
|
.filter((it) => it.message_type !== "circle_video")
|
||||||
.sort((a, b) => b.id - a.id)
|
.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 }));
|
.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);
|
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") {
|
if (messageType === "image" || messageType === "video" || messageType === "circle_video") {
|
||||||
const mediaItems = attachments
|
const mediaItems = attachments
|
||||||
|
.filter((item) => item.message_type !== "circle_video")
|
||||||
.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/"))
|
.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/"))
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
url: item.file_url,
|
url: item.file_url,
|
||||||
@@ -1113,13 +1114,14 @@ function renderMessageContent(
|
|||||||
}
|
}
|
||||||
if (mediaItems.length === 1) {
|
if (mediaItems.length === 1) {
|
||||||
const item = mediaItems[0];
|
const item = mediaItems[0];
|
||||||
|
const isCircleVideo = messageType === "circle_video";
|
||||||
const blockViewerOpen = isStickerOrGifMedia(item.url);
|
const blockViewerOpen = isStickerOrGifMedia(item.url);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<button
|
<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={() => {
|
onClick={() => {
|
||||||
if (blockViewerOpen) {
|
if (blockViewerOpen || isCircleVideo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
opts.onOpenMedia(item.url, item.type);
|
opts.onOpenMedia(item.url, item.type);
|
||||||
@@ -1131,10 +1133,15 @@ function renderMessageContent(
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{item.type === "image" ? (
|
{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>
|
<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] ?? [];
|
const attachments = attachmentsByMessage[message.id] ?? [];
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
if (!attachment.file_url) continue;
|
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 (!attachment.file_type.startsWith("image/") && !attachment.file_type.startsWith("video/")) continue;
|
||||||
if (isStickerOrGifMedia(attachment.file_url)) continue;
|
if (isStickerOrGifMedia(attachment.file_url)) continue;
|
||||||
const type = attachment.file_type.startsWith("image/") ? "image" : "video";
|
const type = attachment.file_type.startsWith("image/") ? "image" : "video";
|
||||||
@@ -1419,7 +1427,7 @@ function collectMediaItems(
|
|||||||
items.push({ url: attachment.file_url, type });
|
items.push({ url: attachment.file_url, type });
|
||||||
}
|
}
|
||||||
if (attachments.length === 0 && message.text && /^https?:\/\//i.test(message.text)) {
|
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;
|
if (isStickerOrGifMedia(message.text)) continue;
|
||||||
const type = message.type === "image" ? "image" : "video";
|
const type = message.type === "image" ? "image" : "video";
|
||||||
const key = `${type}:${message.text}`;
|
const key = `${type}:${message.text}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user