diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e101439..6ed35c1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -51,6 +51,9 @@ + handleReply(intent, chatId) + NotificationIntentExtras.ACTION_MARK_READ -> handleMarkRead(intent, chatId) + } + } finally { + pendingResult.finish() + } + } + } + + private suspend fun handleReply(intent: Intent, chatId: Long) { + val replyText = RemoteInput.getResultsFromIntent(intent) + ?.getCharSequence(NotificationIntentExtras.EXTRA_NOTIFICATION_REPLY_TEXT) + ?.toString() + ?.trim() + .orEmpty() + if (replyText.isBlank()) return + sendTextMessageUseCase(chatId = chatId, text = replyText) + val messageId = intent.getLongExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, -1L) + if (messageId > 0L) { + markMessageReadUseCase(chatId = chatId, messageId = messageId) + } + notificationDispatcher.clearChatNotifications(chatId) + } + + private suspend fun handleMarkRead(intent: Intent, chatId: Long) { + val messageId = intent.getLongExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, -1L) + if (messageId > 0L) { + markMessageReadUseCase(chatId = chatId, messageId = messageId) + } + notificationDispatcher.clearChatNotifications(chatId) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt index 158210b..d6bae65 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt @@ -8,8 +8,10 @@ import android.content.pm.PackageManager import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext +import ru.daemonlord.messenger.R import ru.daemonlord.messenger.MainActivity import javax.inject.Inject import javax.inject.Singleton @@ -51,6 +53,31 @@ class NotificationDispatcher @Inject constructor( openIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + val replyRemoteInput = RemoteInput.Builder(NotificationIntentExtras.EXTRA_NOTIFICATION_REPLY_TEXT) + .setLabel(context.getString(R.string.notification_action_reply_hint)) + .build() + val replyIntent = Intent(context, NotificationActionReceiver::class.java) + .setAction(NotificationIntentExtras.ACTION_REPLY) + .putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId) + .putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L) + .putExtra(NotificationIntentExtras.EXTRA_CHAT_TITLE, payload.title) + val replyPendingIntent = PendingIntent.getBroadcast( + context, + chatNotificationId(payload.chatId) + ACTION_REQUEST_CODE_REPLY_OFFSET, + replyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + val markReadIntent = Intent(context, NotificationActionReceiver::class.java) + .setAction(NotificationIntentExtras.ACTION_MARK_READ) + .putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId) + .putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L) + .putExtra(NotificationIntentExtras.EXTRA_CHAT_TITLE, payload.title) + val markReadPendingIntent = PendingIntent.getBroadcast( + context, + chatNotificationId(payload.chatId) + ACTION_REQUEST_CODE_READ_OFFSET, + markReadIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) val contentText = when { state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body @@ -74,6 +101,23 @@ class NotificationDispatcher @Inject constructor( .setPriority( if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT ) + .addAction( + NotificationCompat.Action.Builder( + 0, + context.getString(R.string.notification_action_mark_read), + markReadPendingIntent, + ).build(), + ) + .addAction( + NotificationCompat.Action.Builder( + 0, + context.getString(R.string.notification_action_reply), + replyPendingIntent, + ) + .addRemoteInput(replyRemoteInput) + .setAllowGeneratedReplies(true) + .build(), + ) .build() val manager = NotificationManagerCompat.from(context) @@ -166,5 +210,7 @@ class NotificationDispatcher @Inject constructor( private const val GROUP_KEY_CHATS = "messenger_chats_group" private const val SUMMARY_NOTIFICATION_ID = 0x4D53_4752 // "MSGR" private const val MAX_LINES = 5 + private const val ACTION_REQUEST_CODE_REPLY_OFFSET = 101 + private const val ACTION_REQUEST_CODE_READ_OFFSET = 202 } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt index 6ab04e3..eaad261 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt @@ -11,4 +11,9 @@ data class ChatNotificationPayload( object NotificationIntentExtras { const val EXTRA_CHAT_ID = "extra_chat_id" const val EXTRA_MESSAGE_ID = "extra_message_id" + const val EXTRA_CHAT_TITLE = "extra_chat_title" + const val EXTRA_NOTIFICATION_ACTION = "extra_notification_action" + const val EXTRA_NOTIFICATION_REPLY_TEXT = "extra_notification_reply_text" + const val ACTION_MARK_READ = "ru.daemonlord.messenger.action.MARK_READ" + const val ACTION_REPLY = "ru.daemonlord.messenger.action.REPLY" } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 0c83d25..bd349b2 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -77,6 +77,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.derivedStateOf +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -117,6 +120,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.safeDrawing import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.layout.onSizeChanged @@ -213,7 +217,6 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.effect.Crop import androidx.media3.effect.Presentation -import androidx.media3.effect.ScaleAndRotateTransformation import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.AspectRatioFrameLayout @@ -239,6 +242,8 @@ fun ChatRoute( val lifecycleOwner = LocalLifecycleOwner.current val voiceRecorder = remember(context) { VoiceRecorder(context) } var showCircleRecorder by remember { mutableStateOf(false) } + var isCircleRecordingLocked by remember { mutableStateOf(false) } + var pendingCircleFinalizeAction by remember { mutableStateOf(null) } var cameraCaptureMode by remember { mutableStateOf(null) } var hasAudioPermission by remember { mutableStateOf( @@ -259,6 +264,11 @@ fun ChatRoute( var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) } var openCircleAfterPermissionGrant by remember { mutableStateOf(false) } var pendingCameraCaptureMode by remember { mutableStateOf(null) } + val openCircleRecorder: () -> Unit = { + pendingCircleFinalizeAction = null + isCircleRecordingLocked = false + showCircleRecorder = true + } val startVoiceRecording: () -> Unit = { val started = voiceRecorder.start() if (started) { @@ -284,8 +294,8 @@ fun ChatRoute( ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED hasAudioPermission = grants[Manifest.permission.RECORD_AUDIO] == true || ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED - if (hasCameraPermission && openCircleAfterPermissionGrant) { - showCircleRecorder = true + if (hasCameraPermission && hasAudioPermission && openCircleAfterPermissionGrant) { + openCircleRecorder() } openCircleAfterPermissionGrant = false val pendingMode = pendingCameraCaptureMode @@ -354,8 +364,8 @@ fun ChatRoute( } }, onCaptureCircleVideo = { - if (hasCameraPermission) { - showCircleRecorder = true + if (hasCameraPermission && hasAudioPermission) { + openCircleRecorder() } else { openCircleAfterPermissionGrant = true circlePermissionLauncher.launch( @@ -406,6 +416,22 @@ fun ChatRoute( viewModel.onVoiceRecordCancelled() } }, + isCircleRecordLocked = isCircleRecordingLocked, + onCircleRecordLock = { + if (showCircleRecorder) { + isCircleRecordingLocked = true + } + }, + onCircleRecordCancel = { + if (showCircleRecorder) { + pendingCircleFinalizeAction = CircleFinalizeAction.Cancel + } + }, + onCircleRecordSend = { + if (showCircleRecorder) { + pendingCircleFinalizeAction = CircleFinalizeAction.Send + } + }, onInlineSearchChanged = viewModel::onInlineSearchChanged, onJumpInlineSearch = viewModel::jumpInlineSearch, onVisibleIncomingMessageId = viewModel::onVisibleIncomingMessageId, @@ -423,13 +449,22 @@ fun ChatRoute( if (showCircleRecorder) { CircleVideoRecorderDialog( lifecycleOwner = lifecycleOwner, - onDismiss = { showCircleRecorder = false }, + isLocked = isCircleRecordingLocked, + pendingFinalizeAction = pendingCircleFinalizeAction, + onFinalizeActionConsumed = { pendingCircleFinalizeAction = null }, + onDismiss = { + pendingCircleFinalizeAction = null + isCircleRecordingLocked = false + showCircleRecorder = false + }, onSend = { payload -> viewModel.onMediaPicked( fileName = payload.fileName, mimeType = payload.mimeType, bytes = payload.bytes, ) + pendingCircleFinalizeAction = null + isCircleRecordingLocked = false showCircleRecorder = false }, ) @@ -481,6 +516,10 @@ private data class ChatScreenActions( val onVoiceRecordLock: () -> Unit, val onVoiceRecordCancel: () -> Unit, val onVoiceRecordSend: () -> Unit, + val isCircleRecordLocked: Boolean, + val onCircleRecordLock: () -> Unit, + val onCircleRecordCancel: () -> Unit, + val onCircleRecordSend: () -> Unit, val onInlineSearchChanged: (String) -> Unit, val onJumpInlineSearch: (Boolean) -> Unit, val onVisibleIncomingMessageId: (Long?) -> Unit, @@ -529,6 +568,10 @@ private fun ChatScreen( val onVoiceRecordLock = actions.onVoiceRecordLock val onVoiceRecordCancel = actions.onVoiceRecordCancel val onVoiceRecordSend = actions.onVoiceRecordSend + val isCircleRecordLocked = actions.isCircleRecordLocked + val onCircleRecordLock = actions.onCircleRecordLock + val onCircleRecordCancel = actions.onCircleRecordCancel + val onCircleRecordSend = actions.onCircleRecordSend val onInlineSearchChanged = actions.onInlineSearchChanged val onJumpInlineSearch = actions.onJumpInlineSearch val onVisibleIncomingMessageId = actions.onVisibleIncomingMessageId @@ -1821,14 +1864,20 @@ private fun ChatScreen( } else { UnifiedRecordButton( enabled = state.canSendMessages && !state.isUploadingMedia, - isLocked = state.isVoiceLocked, + isLocked = if (useCircleRecording) isCircleRecordLocked else state.isVoiceLocked, useCircleRecording = useCircleRecording, onToggleMode = { useCircleRecording = !useCircleRecording }, onStartVoice = onVoiceRecordStart, onStartCircle = onCaptureCircleVideo, - onLock = onVoiceRecordLock, - onCancel = onVoiceRecordCancel, - onRelease = onVoiceRecordSend, + onLock = { + if (useCircleRecording) onCircleRecordLock() else onVoiceRecordLock() + }, + onCancel = { + if (useCircleRecording) onCircleRecordCancel() else onVoiceRecordCancel() + }, + onRelease = { + if (useCircleRecording) onCircleRecordSend() else onVoiceRecordSend() + }, ) } } @@ -2055,6 +2104,7 @@ private fun ChatMediaViewerOverlay( val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0))) { items.size } + var showTopBar by remember { mutableStateOf(true) } Surface( modifier = Modifier @@ -2066,22 +2116,28 @@ private fun ChatMediaViewerOverlay( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceBetween, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + AnimatedVisibility( + visible = showTopBar, + enter = fadeIn(), + exit = fadeOut(), ) { - IconButton(onClick = onDismiss) { - Icon(imageVector = Icons.Filled.Close, contentDescription = "Close viewer", tint = Color.White) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onDismiss) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "Close viewer", tint = Color.White) + } + Text( + text = "${pagerState.currentPage + 1}/${items.size.coerceAtLeast(1)}", + style = MaterialTheme.typography.titleSmall, + color = Color.White, + ) + Spacer(modifier = Modifier.size(48.dp)) } - Text( - text = "${pagerState.currentPage + 1}/${items.size.coerceAtLeast(1)}", - style = MaterialTheme.typography.titleSmall, - color = Color.White, - ) - Spacer(modifier = Modifier.size(48.dp)) } HorizontalPager( @@ -2093,11 +2149,13 @@ private fun ChatMediaViewerOverlay( ChatViewerMediaType.Image -> ZoomableImageViewerPage( imageUrl = item.url, onDismiss = onDismiss, + onToggleChrome = { showTopBar = !showTopBar }, ) ChatViewerMediaType.Video -> VideoViewerPage( videoUrl = item.url, isCurrentPage = pagerState.currentPage == page, onDismiss = onDismiss, + onToggleChrome = { showTopBar = !showTopBar }, ) } } @@ -2113,6 +2171,7 @@ private fun ChatMediaViewerOverlay( private fun ZoomableImageViewerPage( imageUrl: String, onDismiss: () -> Unit, + onToggleChrome: () -> Unit, ) { var scale by remember(imageUrl) { mutableStateOf(1f) } var offset by remember(imageUrl) { mutableStateOf(Offset.Zero) } @@ -2150,6 +2209,11 @@ private fun ZoomableImageViewerPage( dismissOffsetY = 0f }, ) + } + .pointerInput(imageUrl) { + detectTapGestures( + onTap = { onToggleChrome() }, + ) }, contentAlignment = Alignment.Center, ) { @@ -2175,6 +2239,7 @@ private fun VideoViewerPage( videoUrl: String, isCurrentPage: Boolean, onDismiss: () -> Unit, + onToggleChrome: () -> Unit, ) { val playerState = rememberManagedMediaPlayerState( url = videoUrl, @@ -2210,6 +2275,11 @@ private fun VideoViewerPage( }, ) } + .pointerInput(videoUrl) { + detectTapGestures( + onTap = { onToggleChrome() }, + ) + } .graphicsLayer { translationY = dismissOffsetY }, @@ -2911,6 +2981,9 @@ private fun CameraCaptureDialog( @Composable private fun CircleVideoRecorderDialog( lifecycleOwner: androidx.lifecycle.LifecycleOwner, + isLocked: Boolean, + pendingFinalizeAction: CircleFinalizeAction?, + onFinalizeActionConsumed: () -> Unit, onDismiss: () -> Unit, onSend: (PickedMediaPayload) -> Unit, ) { @@ -2928,15 +3001,22 @@ private fun CircleVideoRecorderDialog( var activeRecording by remember { mutableStateOf(null) } var activeFile by remember { mutableStateOf(null) } var isRecording by remember { mutableStateOf(false) } - var isLocked by remember { mutableStateOf(false) } var isProcessing by remember { mutableStateOf(false) } var durationMs by remember { mutableStateOf(0L) } var finalizeAction by remember { mutableStateOf(null) } - var autoStartPending by remember { mutableStateOf(true) } - var recordedLensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_FRONT) } val recordingProgress = (durationMs / 60_000f).coerceIn(0f, 1f) val progressTrackColor = Color.White.copy(alpha = 0.22f) val progressActiveColor = Color.White + val recordingPulse = rememberInfiniteTransition(label = "circle_recording_pulse") + val pulseAlpha by recordingPulse.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 850, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "circle_recording_dot", + ) SideEffect { previewView.scaleX = if (lensFacing == CameraSelector.LENS_FACING_FRONT) -1f else 1f @@ -3009,8 +3089,6 @@ private fun CircleVideoRecorderDialog( val file = createTempCaptureFile(context = context, prefix = "circle_", suffix = ".mp4") activeFile = file durationMs = 0L - isLocked = false - recordedLensFacing = lensFacing val output = FileOutputOptions.Builder(file).build() var pending = capture.output.prepareRecording(context, output) if ( @@ -3044,7 +3122,6 @@ private fun CircleVideoRecorderDialog( transcodeCircleVideoToSquare( context = context, inputFile = completedFile, - mirrorHorizontally = recordedLensFacing == CameraSelector.LENS_FACING_FRONT, ) }.getOrNull() val sourceFile = squaredFile ?: completedFile @@ -3077,12 +3154,17 @@ private fun CircleVideoRecorderDialog( } } - LaunchedEffect(videoCapture, autoStartPending) { - if (autoStartPending && videoCapture != null && activeRecording == null) { - autoStartPending = false + LaunchedEffect(videoCapture) { + if (videoCapture != null && activeRecording == null && !isProcessing) { startRecording() } } + LaunchedEffect(pendingFinalizeAction, activeRecording) { + val action = pendingFinalizeAction ?: return@LaunchedEffect + if (activeRecording == null) return@LaunchedEffect + stopWith(action) + onFinalizeActionConsumed() + } Surface( modifier = Modifier @@ -3183,7 +3265,13 @@ private fun CircleVideoRecorderDialog( modifier = Modifier .size(8.dp) .clip(CircleShape) - .background(if (isRecording) Color.Red else Color.White.copy(alpha = 0.45f)), + .background( + if (isRecording) { + Color.Red.copy(alpha = pulseAlpha) + } else { + Color.White.copy(alpha = 0.45f) + }, + ), ) Text( text = if (isProcessing) "Processing…" else formatDuration(durationMs.toInt()), @@ -3200,7 +3288,6 @@ private fun CircleVideoRecorderDialog( } else { CameraSelector.LENS_FACING_BACK } - autoStartPending = true } }, enabled = !isRecording, @@ -3223,72 +3310,36 @@ private fun CircleVideoRecorderDialog( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = if (isLocked) "Locked" else "← Отмена", - color = Color.White.copy(alpha = 0.8f), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = if (isLocked) "Tap to send" else "↑ Lock", - color = Color.White.copy(alpha = 0.72f), - style = MaterialTheme.typography.bodySmall, - ) - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(68.dp) - .clip(CircleShape) - .pointerInput(videoCapture, isRecording, isLocked, isProcessing) { - if (videoCapture == null || isProcessing) return@pointerInput - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - if (!isRecording) { - startRecording() - waitForUpOrCancellation() - return@awaitEachGesture - } - if (isLocked) { - waitForUpOrCancellation() - stopWith(CircleFinalizeAction.Send) - return@awaitEachGesture - } - var cancelled = false - var locked = false - while (true) { - val event = awaitPointerEvent() - val change = event.changes.first() - val delta = change.position - down.position - if (!locked && delta.y < -120f) { - locked = true - isLocked = true - break - } - if (!locked && delta.x < -150f) { - cancelled = true - stopWith(CircleFinalizeAction.Cancel) - break - } - if (!change.pressed) { - if (!cancelled && !locked) { - stopWith(CircleFinalizeAction.Send) - } - break - } - if (change.positionChange() != Offset.Zero) { - change.consume() - } - } - } - }, - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = if (isRecording) Icons.AutoMirrored.Filled.Send else Icons.Filled.RadioButtonChecked, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, + if (isLocked) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { stopWith(CircleFinalizeAction.Cancel) }, + enabled = isRecording && !isProcessing, + ) { + Text(stringResource(id = R.string.common_cancel), color = Color.White) + } + Text( + text = stringResource(id = R.string.chat_voice_hint_locked), + color = Color.White.copy(alpha = 0.78f), + style = MaterialTheme.typography.bodyMedium, ) + Button( + onClick = { stopWith(CircleFinalizeAction.Send) }, + enabled = isRecording && !isProcessing, + ) { + Text(stringResource(id = R.string.common_send)) + } } + } else { + Text( + text = stringResource(id = R.string.chat_voice_hint_slide), + color = Color.White.copy(alpha = 0.82f), + style = MaterialTheme.typography.bodyMedium, + ) } } } @@ -4284,6 +4335,16 @@ private fun VoiceRecordingStatusRow( onCancel: () -> Unit, onSend: () -> Unit, ) { + val pulseTransition = rememberInfiniteTransition(label = "voice_recording_pulse") + val pulseAlpha by pulseTransition.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 850, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "voice_recording_dot", + ) Column( modifier = Modifier .fillMaxWidth() @@ -4295,14 +4356,25 @@ private fun VoiceRecordingStatusRow( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = stringResource( - id = R.string.chat_voice_recording_duration, - formatDuration(durationMs.toInt()), - ), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(Color.Red.copy(alpha = pulseAlpha)), + ) + Text( + text = stringResource( + id = R.string.chat_voice_recording_duration, + formatDuration(durationMs.toInt()), + ), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + } if (!hint.isNullOrBlank()) { Text( text = hint, @@ -4711,42 +4783,32 @@ private fun UnifiedRecordButton( } if (useCircleRecording) { onStartCircle() - while (true) { - val event = awaitPointerEvent() - val change = event.changes.first() - if (!change.pressed) { - break - } - if (change.positionChange() != Offset.Zero) { - change.consume() - } - } } else { onStartVoice() - var cancelledBySlide = false - var lockedBySlide = false - while (true) { - val event = awaitPointerEvent() - val change = event.changes.first() - val delta = change.position - down.position - if (!lockedBySlide && delta.y < -120f) { - lockedBySlide = true - onLock() - } - if (!lockedBySlide && delta.x < -150f) { - cancelledBySlide = true - onCancel() - break - } - if (!change.pressed) { - if (!cancelledBySlide && !lockedBySlide) { - onRelease() - } - break - } - if (change.positionChange() != Offset.Zero) { - change.consume() + } + var cancelledBySlide = false + var lockedBySlide = false + while (true) { + val event = awaitPointerEvent() + val change = event.changes.first() + val delta = change.position - down.position + if (!lockedBySlide && delta.y < -120f) { + lockedBySlide = true + onLock() + } + if (!lockedBySlide && delta.x < -150f) { + cancelledBySlide = true + onCancel() + break + } + if (!change.pressed) { + if (!cancelledBySlide && !lockedBySlide) { + onRelease() } + break + } + if (change.positionChange() != Offset.Zero) { + change.consume() } } } @@ -4798,20 +4860,12 @@ private fun resolveRemoteAudioDurationMs(url: String): Int? { private suspend fun transcodeCircleVideoToSquare( context: Context, inputFile: File, - mirrorHorizontally: Boolean, ): File = suspendCancellableCoroutine { continuation -> val outputFile = createTempCaptureFile(context = context, prefix = "circle_square_", suffix = ".mp4") val frameInfo = readVideoFrameInfo(inputFile) val squareCrop = frameInfo?.toCenteredSquareCrop() val transformer = Transformer.Builder(context).build() val videoEffects = buildList { - if (mirrorHorizontally) { - add( - ScaleAndRotateTransformation.Builder() - .setScale(-1f, 1f) - .build(), - ) - } if (squareCrop != null) { add(squareCrop) } else { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index d7dbda0..aff5aab 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -808,12 +808,6 @@ class ChatViewModel @Inject constructor( it.copy( chatMembers = (membersResult as? AppResult.Success)?.data ?: it.chatMembers, chatBans = (bansResult as? AppResult.Success)?.data ?: it.chatBans, - errorMessage = listOf(membersResult, bansResult) - .filterIsInstance() - .firstOrNull() - ?.reason - ?.toUiMessage() - ?: it.errorMessage, ) } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt index 6c2306e..0c14e00 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt @@ -1,5 +1,9 @@ package ru.daemonlord.messenger.ui.chat +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.text.ClickableText import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -80,29 +84,35 @@ fun FormattedMessageText( baseColor = color, revealedSpoilers = revealedSpoilers.toSet(), ) - ClickableText( - text = annotated, - style = style, - modifier = modifier, - onClick = { offset -> - val spoiler = annotated - .getStringAnnotations(tag = "spoiler", start = offset, end = offset) - .firstOrNull() - if (spoiler != null) { - if (!revealedSpoilers.contains(spoiler.item)) { - revealedSpoilers.add(spoiler.item) + AnimatedContent( + targetState = annotated, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "spoiler_reveal", + ) { animatedAnnotated -> + ClickableText( + text = animatedAnnotated, + style = style, + modifier = modifier, + onClick = { offset -> + val spoiler = animatedAnnotated + .getStringAnnotations(tag = "spoiler", start = offset, end = offset) + .firstOrNull() + if (spoiler != null) { + if (!revealedSpoilers.contains(spoiler.item)) { + revealedSpoilers.add(spoiler.item) + } + return@ClickableText } - return@ClickableText - } - annotated - .getStringAnnotations(tag = "url", start = offset, end = offset) - .firstOrNull() - ?.item - ?.let { link -> - runCatching { uriHandler.openUri(link) } - } - }, - ) + animatedAnnotated + .getStringAnnotations(tag = "url", start = offset, end = offset) + .firstOrNull() + ?.item + ?.let { link -> + runCatching { uriHandler.openUri(link) } + } + }, + ) + } } private fun buildFormattedMessage( diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 4db39ae..b12d9ad 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -80,6 +80,9 @@ Отправить Сохранить Неизвестный пользователь + Ответить + Введите ответ + Прочитано Аватар Пользователь Выбрать фото diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index cf2e1f8..e3d0375 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -80,6 +80,9 @@ Send Save Unknown user + Reply + Write a reply + Mark as read Avatar User Choose photo diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 5f3edee..ce59725 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -1116,36 +1116,49 @@ function renderMessageContent( if (mediaItems.length === 1) { const item = mediaItems[0]; const blockViewerOpen = isStickerOrGifMedia(item.url); + const isInteractiveCircle = isCircleVideo && item.type === "video"; return (
- + {isInteractiveCircle ? ( +
{ + event.stopPropagation(); + opts.onAttachmentContextMenu(event, item.url); + }} + > + +
+ ) : ( + + )} {captionText ? (

) : null} @@ -1500,6 +1513,71 @@ async function downloadFileFromUrl(url: string): Promise { window.URL.revokeObjectURL(blobUrl); } +function CircleVideoInlinePlayer({ src }: { src: string }) { + const videoRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + + useEffect(() => { + const video = videoRef.current; + if (!video) { + return; + } + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + const handleEnded = () => { + setIsPlaying(false); + video.currentTime = 0; + }; + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + video.addEventListener("ended", handleEnded); + return () => { + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + video.removeEventListener("ended", handleEnded); + }; + }, [src]); + + async function togglePlayback() { + const video = videoRef.current; + if (!video) { + return; + } + if (video.paused || video.ended) { + try { + await video.play(); + } catch { + setIsPlaying(false); + } + return; + } + video.pause(); + } + + return ( +

+
+ ); +} + function AudioInlinePlayer({ src, title }: { src: string; title: string }) { const track = useAudioPlayerStore((s) => s.track); const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);