feat: improve chat media and notification interactions
Some checks failed
Android CI / android (push) Failing after 4m12s
Android Release / release (push) Has started running
CI / test (push) Has started running

fix: reveal spoiler text on tap with animated transitions

fix: render and play circle videos correctly on web

feat: add quick reply and mark-as-read notification actions

fix: stabilize circle recording gestures and chat auxiliary error handling
This commit is contained in:
2026-04-05 14:48:36 +03:00
parent 2dcd1ba129
commit e8f9efb108
10 changed files with 465 additions and 204 deletions

View File

@@ -51,6 +51,9 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" /> <action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter> </intent-filter>
</service> </service>
<receiver
android:name=".core.notifications.NotificationActionReceiver"
android:exported="false" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"

View File

@@ -0,0 +1,65 @@
package ru.daemonlord.messenger.core.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
import javax.inject.Inject
@AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() {
@Inject
lateinit var sendTextMessageUseCase: SendTextMessageUseCase
@Inject
lateinit var markMessageReadUseCase: MarkMessageReadUseCase
@Inject
lateinit var notificationDispatcher: NotificationDispatcher
override fun onReceive(context: Context, intent: Intent) {
val chatId = intent.getLongExtra(NotificationIntentExtras.EXTRA_CHAT_ID, -1L)
if (chatId <= 0L) return
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
when (intent.action) {
NotificationIntentExtras.ACTION_REPLY -> 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)
}
}

View File

@@ -8,8 +8,10 @@ import android.content.pm.PackageManager
import android.os.Build 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.app.RemoteInput
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.MainActivity import ru.daemonlord.messenger.MainActivity
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -51,6 +53,31 @@ class NotificationDispatcher @Inject constructor(
openIntent, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, 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 { val contentText = when {
state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body
@@ -74,6 +101,23 @@ class NotificationDispatcher @Inject constructor(
.setPriority( .setPriority(
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT 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() .build()
val manager = NotificationManagerCompat.from(context) 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 GROUP_KEY_CHATS = "messenger_chats_group"
private const val SUMMARY_NOTIFICATION_ID = 0x4D53_4752 // "MSGR" private const val SUMMARY_NOTIFICATION_ID = 0x4D53_4752 // "MSGR"
private const val MAX_LINES = 5 private const val MAX_LINES = 5
private const val ACTION_REQUEST_CODE_REPLY_OFFSET = 101
private const val ACTION_REQUEST_CODE_READ_OFFSET = 202
} }
} }

View File

@@ -11,4 +11,9 @@ data class ChatNotificationPayload(
object NotificationIntentExtras { object NotificationIntentExtras {
const val EXTRA_CHAT_ID = "extra_chat_id" const val EXTRA_CHAT_ID = "extra_chat_id"
const val EXTRA_MESSAGE_ID = "extra_message_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"
} }

View File

@@ -77,6 +77,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.derivedStateOf 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.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat 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.foundation.layout.safeDrawing
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
@@ -213,7 +217,6 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.Crop import androidx.media3.effect.Crop
import androidx.media3.effect.Presentation import androidx.media3.effect.Presentation
import androidx.media3.effect.ScaleAndRotateTransformation
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
@@ -239,6 +242,8 @@ fun ChatRoute(
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val voiceRecorder = remember(context) { VoiceRecorder(context) } val voiceRecorder = remember(context) { VoiceRecorder(context) }
var showCircleRecorder by remember { mutableStateOf(false) } var showCircleRecorder by remember { mutableStateOf(false) }
var isCircleRecordingLocked by remember { mutableStateOf(false) }
var pendingCircleFinalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) }
var cameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(null) } var cameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(null) }
var hasAudioPermission by remember { var hasAudioPermission by remember {
mutableStateOf( mutableStateOf(
@@ -259,6 +264,11 @@ fun ChatRoute(
var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) } var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) }
var openCircleAfterPermissionGrant by remember { mutableStateOf(false) } var openCircleAfterPermissionGrant by remember { mutableStateOf(false) }
var pendingCameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(null) } var pendingCameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(null) }
val openCircleRecorder: () -> Unit = {
pendingCircleFinalizeAction = null
isCircleRecordingLocked = false
showCircleRecorder = true
}
val startVoiceRecording: () -> Unit = { val startVoiceRecording: () -> Unit = {
val started = voiceRecorder.start() val started = voiceRecorder.start()
if (started) { if (started) {
@@ -284,8 +294,8 @@ fun ChatRoute(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
hasAudioPermission = grants[Manifest.permission.RECORD_AUDIO] == true || hasAudioPermission = grants[Manifest.permission.RECORD_AUDIO] == true ||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
if (hasCameraPermission && openCircleAfterPermissionGrant) { if (hasCameraPermission && hasAudioPermission && openCircleAfterPermissionGrant) {
showCircleRecorder = true openCircleRecorder()
} }
openCircleAfterPermissionGrant = false openCircleAfterPermissionGrant = false
val pendingMode = pendingCameraCaptureMode val pendingMode = pendingCameraCaptureMode
@@ -354,8 +364,8 @@ fun ChatRoute(
} }
}, },
onCaptureCircleVideo = { onCaptureCircleVideo = {
if (hasCameraPermission) { if (hasCameraPermission && hasAudioPermission) {
showCircleRecorder = true openCircleRecorder()
} else { } else {
openCircleAfterPermissionGrant = true openCircleAfterPermissionGrant = true
circlePermissionLauncher.launch( circlePermissionLauncher.launch(
@@ -406,6 +416,22 @@ fun ChatRoute(
viewModel.onVoiceRecordCancelled() viewModel.onVoiceRecordCancelled()
} }
}, },
isCircleRecordLocked = isCircleRecordingLocked,
onCircleRecordLock = {
if (showCircleRecorder) {
isCircleRecordingLocked = true
}
},
onCircleRecordCancel = {
if (showCircleRecorder) {
pendingCircleFinalizeAction = CircleFinalizeAction.Cancel
}
},
onCircleRecordSend = {
if (showCircleRecorder) {
pendingCircleFinalizeAction = CircleFinalizeAction.Send
}
},
onInlineSearchChanged = viewModel::onInlineSearchChanged, onInlineSearchChanged = viewModel::onInlineSearchChanged,
onJumpInlineSearch = viewModel::jumpInlineSearch, onJumpInlineSearch = viewModel::jumpInlineSearch,
onVisibleIncomingMessageId = viewModel::onVisibleIncomingMessageId, onVisibleIncomingMessageId = viewModel::onVisibleIncomingMessageId,
@@ -423,13 +449,22 @@ fun ChatRoute(
if (showCircleRecorder) { if (showCircleRecorder) {
CircleVideoRecorderDialog( CircleVideoRecorderDialog(
lifecycleOwner = lifecycleOwner, lifecycleOwner = lifecycleOwner,
onDismiss = { showCircleRecorder = false }, isLocked = isCircleRecordingLocked,
pendingFinalizeAction = pendingCircleFinalizeAction,
onFinalizeActionConsumed = { pendingCircleFinalizeAction = null },
onDismiss = {
pendingCircleFinalizeAction = null
isCircleRecordingLocked = false
showCircleRecorder = false
},
onSend = { payload -> onSend = { payload ->
viewModel.onMediaPicked( viewModel.onMediaPicked(
fileName = payload.fileName, fileName = payload.fileName,
mimeType = payload.mimeType, mimeType = payload.mimeType,
bytes = payload.bytes, bytes = payload.bytes,
) )
pendingCircleFinalizeAction = null
isCircleRecordingLocked = false
showCircleRecorder = false showCircleRecorder = false
}, },
) )
@@ -481,6 +516,10 @@ private data class ChatScreenActions(
val onVoiceRecordLock: () -> Unit, val onVoiceRecordLock: () -> Unit,
val onVoiceRecordCancel: () -> Unit, val onVoiceRecordCancel: () -> Unit,
val onVoiceRecordSend: () -> Unit, val onVoiceRecordSend: () -> Unit,
val isCircleRecordLocked: Boolean,
val onCircleRecordLock: () -> Unit,
val onCircleRecordCancel: () -> Unit,
val onCircleRecordSend: () -> Unit,
val onInlineSearchChanged: (String) -> Unit, val onInlineSearchChanged: (String) -> Unit,
val onJumpInlineSearch: (Boolean) -> Unit, val onJumpInlineSearch: (Boolean) -> Unit,
val onVisibleIncomingMessageId: (Long?) -> Unit, val onVisibleIncomingMessageId: (Long?) -> Unit,
@@ -529,6 +568,10 @@ private fun ChatScreen(
val onVoiceRecordLock = actions.onVoiceRecordLock val onVoiceRecordLock = actions.onVoiceRecordLock
val onVoiceRecordCancel = actions.onVoiceRecordCancel val onVoiceRecordCancel = actions.onVoiceRecordCancel
val onVoiceRecordSend = actions.onVoiceRecordSend 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 onInlineSearchChanged = actions.onInlineSearchChanged
val onJumpInlineSearch = actions.onJumpInlineSearch val onJumpInlineSearch = actions.onJumpInlineSearch
val onVisibleIncomingMessageId = actions.onVisibleIncomingMessageId val onVisibleIncomingMessageId = actions.onVisibleIncomingMessageId
@@ -1821,14 +1864,20 @@ private fun ChatScreen(
} else { } else {
UnifiedRecordButton( UnifiedRecordButton(
enabled = state.canSendMessages && !state.isUploadingMedia, enabled = state.canSendMessages && !state.isUploadingMedia,
isLocked = state.isVoiceLocked, isLocked = if (useCircleRecording) isCircleRecordLocked else state.isVoiceLocked,
useCircleRecording = useCircleRecording, useCircleRecording = useCircleRecording,
onToggleMode = { useCircleRecording = !useCircleRecording }, onToggleMode = { useCircleRecording = !useCircleRecording },
onStartVoice = onVoiceRecordStart, onStartVoice = onVoiceRecordStart,
onStartCircle = onCaptureCircleVideo, onStartCircle = onCaptureCircleVideo,
onLock = onVoiceRecordLock, onLock = {
onCancel = onVoiceRecordCancel, if (useCircleRecording) onCircleRecordLock() else onVoiceRecordLock()
onRelease = onVoiceRecordSend, },
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))) { val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0))) {
items.size items.size
} }
var showTopBar by remember { mutableStateOf(true) }
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -2066,22 +2116,28 @@ private fun ChatMediaViewerOverlay(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
) { ) {
Row( AnimatedVisibility(
modifier = Modifier visible = showTopBar,
.fillMaxWidth() enter = fadeIn(),
.padding(horizontal = 8.dp, vertical = 8.dp), exit = fadeOut(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = onDismiss) { Row(
Icon(imageVector = Icons.Filled.Close, contentDescription = "Close viewer", tint = Color.White) 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( HorizontalPager(
@@ -2093,11 +2149,13 @@ private fun ChatMediaViewerOverlay(
ChatViewerMediaType.Image -> ZoomableImageViewerPage( ChatViewerMediaType.Image -> ZoomableImageViewerPage(
imageUrl = item.url, imageUrl = item.url,
onDismiss = onDismiss, onDismiss = onDismiss,
onToggleChrome = { showTopBar = !showTopBar },
) )
ChatViewerMediaType.Video -> VideoViewerPage( ChatViewerMediaType.Video -> VideoViewerPage(
videoUrl = item.url, videoUrl = item.url,
isCurrentPage = pagerState.currentPage == page, isCurrentPage = pagerState.currentPage == page,
onDismiss = onDismiss, onDismiss = onDismiss,
onToggleChrome = { showTopBar = !showTopBar },
) )
} }
} }
@@ -2113,6 +2171,7 @@ private fun ChatMediaViewerOverlay(
private fun ZoomableImageViewerPage( private fun ZoomableImageViewerPage(
imageUrl: String, imageUrl: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onToggleChrome: () -> Unit,
) { ) {
var scale by remember(imageUrl) { mutableStateOf(1f) } var scale by remember(imageUrl) { mutableStateOf(1f) }
var offset by remember(imageUrl) { mutableStateOf(Offset.Zero) } var offset by remember(imageUrl) { mutableStateOf(Offset.Zero) }
@@ -2150,6 +2209,11 @@ private fun ZoomableImageViewerPage(
dismissOffsetY = 0f dismissOffsetY = 0f
}, },
) )
}
.pointerInput(imageUrl) {
detectTapGestures(
onTap = { onToggleChrome() },
)
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -2175,6 +2239,7 @@ private fun VideoViewerPage(
videoUrl: String, videoUrl: String,
isCurrentPage: Boolean, isCurrentPage: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onToggleChrome: () -> Unit,
) { ) {
val playerState = rememberManagedMediaPlayerState( val playerState = rememberManagedMediaPlayerState(
url = videoUrl, url = videoUrl,
@@ -2210,6 +2275,11 @@ private fun VideoViewerPage(
}, },
) )
} }
.pointerInput(videoUrl) {
detectTapGestures(
onTap = { onToggleChrome() },
)
}
.graphicsLayer { .graphicsLayer {
translationY = dismissOffsetY translationY = dismissOffsetY
}, },
@@ -2911,6 +2981,9 @@ private fun CameraCaptureDialog(
@Composable @Composable
private fun CircleVideoRecorderDialog( private fun CircleVideoRecorderDialog(
lifecycleOwner: androidx.lifecycle.LifecycleOwner, lifecycleOwner: androidx.lifecycle.LifecycleOwner,
isLocked: Boolean,
pendingFinalizeAction: CircleFinalizeAction?,
onFinalizeActionConsumed: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSend: (PickedMediaPayload) -> Unit, onSend: (PickedMediaPayload) -> Unit,
) { ) {
@@ -2928,15 +3001,22 @@ private fun CircleVideoRecorderDialog(
var activeRecording by remember { mutableStateOf<Recording?>(null) } var activeRecording by remember { mutableStateOf<Recording?>(null) }
var activeFile by remember { mutableStateOf<File?>(null) } var activeFile by remember { mutableStateOf<File?>(null) }
var isRecording by remember { mutableStateOf(false) } var isRecording by remember { mutableStateOf(false) }
var isLocked by remember { mutableStateOf(false) }
var isProcessing by remember { mutableStateOf(false) } var isProcessing by remember { mutableStateOf(false) }
var durationMs by remember { mutableStateOf(0L) } var durationMs by remember { mutableStateOf(0L) }
var finalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) } var finalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(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 recordingProgress = (durationMs / 60_000f).coerceIn(0f, 1f)
val progressTrackColor = Color.White.copy(alpha = 0.22f) val progressTrackColor = Color.White.copy(alpha = 0.22f)
val progressActiveColor = Color.White 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 { SideEffect {
previewView.scaleX = if (lensFacing == CameraSelector.LENS_FACING_FRONT) -1f else 1f 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") val file = createTempCaptureFile(context = context, prefix = "circle_", suffix = ".mp4")
activeFile = file activeFile = file
durationMs = 0L durationMs = 0L
isLocked = false
recordedLensFacing = lensFacing
val output = FileOutputOptions.Builder(file).build() val output = FileOutputOptions.Builder(file).build()
var pending = capture.output.prepareRecording(context, output) var pending = capture.output.prepareRecording(context, output)
if ( if (
@@ -3044,7 +3122,6 @@ private fun CircleVideoRecorderDialog(
transcodeCircleVideoToSquare( transcodeCircleVideoToSquare(
context = context, context = context,
inputFile = completedFile, inputFile = completedFile,
mirrorHorizontally = recordedLensFacing == CameraSelector.LENS_FACING_FRONT,
) )
}.getOrNull() }.getOrNull()
val sourceFile = squaredFile ?: completedFile val sourceFile = squaredFile ?: completedFile
@@ -3077,12 +3154,17 @@ private fun CircleVideoRecorderDialog(
} }
} }
LaunchedEffect(videoCapture, autoStartPending) { LaunchedEffect(videoCapture) {
if (autoStartPending && videoCapture != null && activeRecording == null) { if (videoCapture != null && activeRecording == null && !isProcessing) {
autoStartPending = false
startRecording() startRecording()
} }
} }
LaunchedEffect(pendingFinalizeAction, activeRecording) {
val action = pendingFinalizeAction ?: return@LaunchedEffect
if (activeRecording == null) return@LaunchedEffect
stopWith(action)
onFinalizeActionConsumed()
}
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -3183,7 +3265,13 @@ private fun CircleVideoRecorderDialog(
modifier = Modifier modifier = Modifier
.size(8.dp) .size(8.dp)
.clip(CircleShape) .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(
text = if (isProcessing) "Processing…" else formatDuration(durationMs.toInt()), text = if (isProcessing) "Processing…" else formatDuration(durationMs.toInt()),
@@ -3200,7 +3288,6 @@ private fun CircleVideoRecorderDialog(
} else { } else {
CameraSelector.LENS_FACING_BACK CameraSelector.LENS_FACING_BACK
} }
autoStartPending = true
} }
}, },
enabled = !isRecording, enabled = !isRecording,
@@ -3223,72 +3310,36 @@ private fun CircleVideoRecorderDialog(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( if (isLocked) {
text = if (isLocked) "Locked" else "← Отмена", Row(
color = Color.White.copy(alpha = 0.8f), modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium, horizontalArrangement = Arrangement.SpaceBetween,
) verticalAlignment = Alignment.CenterVertically,
Text( ) {
text = if (isLocked) "Tap to send" else "↑ Lock", TextButton(
color = Color.White.copy(alpha = 0.72f), onClick = { stopWith(CircleFinalizeAction.Cancel) },
style = MaterialTheme.typography.bodySmall, enabled = isRecording && !isProcessing,
) ) {
Surface( Text(stringResource(id = R.string.common_cancel), color = Color.White)
shape = CircleShape, }
color = MaterialTheme.colorScheme.primary, Text(
modifier = Modifier text = stringResource(id = R.string.chat_voice_hint_locked),
.size(68.dp) color = Color.White.copy(alpha = 0.78f),
.clip(CircleShape) style = MaterialTheme.typography.bodyMedium,
.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,
) )
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, onCancel: () -> Unit,
onSend: () -> 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -4295,14 +4356,25 @@ private fun VoiceRecordingStatusRow(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Row(
text = stringResource( verticalAlignment = Alignment.CenterVertically,
id = R.string.chat_voice_recording_duration, horizontalArrangement = Arrangement.spacedBy(8.dp),
formatDuration(durationMs.toInt()), ) {
), Box(
style = MaterialTheme.typography.labelMedium, modifier = Modifier
fontWeight = FontWeight.SemiBold, .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()) { if (!hint.isNullOrBlank()) {
Text( Text(
text = hint, text = hint,
@@ -4711,42 +4783,32 @@ private fun UnifiedRecordButton(
} }
if (useCircleRecording) { if (useCircleRecording) {
onStartCircle() onStartCircle()
while (true) {
val event = awaitPointerEvent()
val change = event.changes.first()
if (!change.pressed) {
break
}
if (change.positionChange() != Offset.Zero) {
change.consume()
}
}
} else { } else {
onStartVoice() onStartVoice()
var cancelledBySlide = false }
var lockedBySlide = false var cancelledBySlide = false
while (true) { var lockedBySlide = false
val event = awaitPointerEvent() while (true) {
val change = event.changes.first() val event = awaitPointerEvent()
val delta = change.position - down.position val change = event.changes.first()
if (!lockedBySlide && delta.y < -120f) { val delta = change.position - down.position
lockedBySlide = true if (!lockedBySlide && delta.y < -120f) {
onLock() lockedBySlide = true
} onLock()
if (!lockedBySlide && delta.x < -150f) { }
cancelledBySlide = true if (!lockedBySlide && delta.x < -150f) {
onCancel() cancelledBySlide = true
break onCancel()
} break
if (!change.pressed) { }
if (!cancelledBySlide && !lockedBySlide) { if (!change.pressed) {
onRelease() if (!cancelledBySlide && !lockedBySlide) {
} onRelease()
break
}
if (change.positionChange() != Offset.Zero) {
change.consume()
} }
break
}
if (change.positionChange() != Offset.Zero) {
change.consume()
} }
} }
} }
@@ -4798,20 +4860,12 @@ private fun resolveRemoteAudioDurationMs(url: String): Int? {
private suspend fun transcodeCircleVideoToSquare( private suspend fun transcodeCircleVideoToSquare(
context: Context, context: Context,
inputFile: File, inputFile: File,
mirrorHorizontally: Boolean,
): File = suspendCancellableCoroutine { continuation -> ): File = suspendCancellableCoroutine { continuation ->
val outputFile = createTempCaptureFile(context = context, prefix = "circle_square_", suffix = ".mp4") val outputFile = createTempCaptureFile(context = context, prefix = "circle_square_", suffix = ".mp4")
val frameInfo = readVideoFrameInfo(inputFile) val frameInfo = readVideoFrameInfo(inputFile)
val squareCrop = frameInfo?.toCenteredSquareCrop() val squareCrop = frameInfo?.toCenteredSquareCrop()
val transformer = Transformer.Builder(context).build() val transformer = Transformer.Builder(context).build()
val videoEffects = buildList { val videoEffects = buildList {
if (mirrorHorizontally) {
add(
ScaleAndRotateTransformation.Builder()
.setScale(-1f, 1f)
.build(),
)
}
if (squareCrop != null) { if (squareCrop != null) {
add(squareCrop) add(squareCrop)
} else { } else {

View File

@@ -808,12 +808,6 @@ class ChatViewModel @Inject constructor(
it.copy( it.copy(
chatMembers = (membersResult as? AppResult.Success)?.data ?: it.chatMembers, chatMembers = (membersResult as? AppResult.Success)?.data ?: it.chatMembers,
chatBans = (bansResult as? AppResult.Success)?.data ?: it.chatBans, chatBans = (bansResult as? AppResult.Success)?.data ?: it.chatBans,
errorMessage = listOf(membersResult, bansResult)
.filterIsInstance<AppResult.Error>()
.firstOrNull()
?.reason
?.toUiMessage()
?: it.errorMessage,
) )
} }
} }

View File

@@ -1,5 +1,9 @@
package ru.daemonlord.messenger.ui.chat 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.foundation.text.ClickableText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -80,29 +84,35 @@ fun FormattedMessageText(
baseColor = color, baseColor = color,
revealedSpoilers = revealedSpoilers.toSet(), revealedSpoilers = revealedSpoilers.toSet(),
) )
ClickableText( AnimatedContent(
text = annotated, targetState = annotated,
style = style, transitionSpec = { fadeIn() togetherWith fadeOut() },
modifier = modifier, label = "spoiler_reveal",
onClick = { offset -> ) { animatedAnnotated ->
val spoiler = annotated ClickableText(
.getStringAnnotations(tag = "spoiler", start = offset, end = offset) text = animatedAnnotated,
.firstOrNull() style = style,
if (spoiler != null) { modifier = modifier,
if (!revealedSpoilers.contains(spoiler.item)) { onClick = { offset ->
revealedSpoilers.add(spoiler.item) 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 animatedAnnotated
} .getStringAnnotations(tag = "url", start = offset, end = offset)
annotated .firstOrNull()
.getStringAnnotations(tag = "url", start = offset, end = offset) ?.item
.firstOrNull() ?.let { link ->
?.item runCatching { uriHandler.openUri(link) }
?.let { link -> }
runCatching { uriHandler.openUri(link) } },
} )
}, }
)
} }
private fun buildFormattedMessage( private fun buildFormattedMessage(

View File

@@ -80,6 +80,9 @@
<string name="common_send">Отправить</string> <string name="common_send">Отправить</string>
<string name="common_save">Сохранить</string> <string name="common_save">Сохранить</string>
<string name="common_unknown_user">Неизвестный пользователь</string> <string name="common_unknown_user">Неизвестный пользователь</string>
<string name="notification_action_reply">Ответить</string>
<string name="notification_action_reply_hint">Введите ответ</string>
<string name="notification_action_mark_read">Прочитано</string>
<string name="profile_avatar_content_description">Аватар</string> <string name="profile_avatar_content_description">Аватар</string>
<string name="profile_user_fallback">Пользователь</string> <string name="profile_user_fallback">Пользователь</string>
<string name="profile_choose_photo">Выбрать фото</string> <string name="profile_choose_photo">Выбрать фото</string>

View File

@@ -80,6 +80,9 @@
<string name="common_send">Send</string> <string name="common_send">Send</string>
<string name="common_save">Save</string> <string name="common_save">Save</string>
<string name="common_unknown_user">Unknown user</string> <string name="common_unknown_user">Unknown user</string>
<string name="notification_action_reply">Reply</string>
<string name="notification_action_reply_hint">Write a reply</string>
<string name="notification_action_mark_read">Mark as read</string>
<string name="profile_avatar_content_description">Avatar</string> <string name="profile_avatar_content_description">Avatar</string>
<string name="profile_user_fallback">User</string> <string name="profile_user_fallback">User</string>
<string name="profile_choose_photo">Choose photo</string> <string name="profile_choose_photo">Choose photo</string>

View File

@@ -1116,36 +1116,49 @@ function renderMessageContent(
if (mediaItems.length === 1) { if (mediaItems.length === 1) {
const item = mediaItems[0]; const item = mediaItems[0];
const blockViewerOpen = isStickerOrGifMedia(item.url); const blockViewerOpen = isStickerOrGifMedia(item.url);
const isInteractiveCircle = isCircleVideo && item.type === "video";
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<button {isInteractiveCircle ? (
className={`relative block overflow-hidden bg-slate-950/30 ${isCircleVideo ? "aspect-square w-56 rounded-full" : "rounded-xl"}`} <div
onClick={() => { className="relative block aspect-square w-56 overflow-hidden rounded-full bg-slate-950/30"
if (blockViewerOpen || isCircleVideo) { onContextMenu={(event) => {
return; event.stopPropagation();
} opts.onAttachmentContextMenu(event, item.url);
opts.onOpenMedia(item.url, item.type); }}
}} >
onContextMenu={(event) => { <CircleVideoInlinePlayer src={item.url} />
event.stopPropagation(); </div>
opts.onAttachmentContextMenu(event, item.url); ) : (
}} <button
type="button" className={`relative block overflow-hidden bg-slate-950/30 ${isCircleVideo ? "aspect-square w-56 rounded-full" : "rounded-xl"}`}
> onClick={() => {
{item.type === "image" ? ( if (blockViewerOpen || isCircleVideo) {
<img return;
alt="attachment" }
className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl object-cover"} opts.onOpenMedia(item.url, item.type);
draggable={false} }}
src={item.url} onContextMenu={(event) => {
/> event.stopPropagation();
) : ( opts.onAttachmentContextMenu(event, item.url);
<> }}
<video className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl"} muted src={item.url} /> type="button"
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85"></span> >
</> {item.type === "image" ? (
)} <img
</button> alt="attachment"
className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl object-cover"}
draggable={false}
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>
</>
)}
</button>
)}
{captionText ? ( {captionText ? (
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} /> <p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
) : null} ) : null}
@@ -1500,6 +1513,71 @@ async function downloadFileFromUrl(url: string): Promise<void> {
window.URL.revokeObjectURL(blobUrl); window.URL.revokeObjectURL(blobUrl);
} }
function CircleVideoInlinePlayer({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement | null>(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 (
<div className="relative h-full w-full">
<video
ref={videoRef}
className="h-full w-full object-cover"
playsInline
preload="metadata"
src={src}
/>
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-black/45 text-2xl text-white">
{isPlaying ? "❚❚" : "▶"}
</span>
</span>
<button
aria-label={isPlaying ? "Pause video message" : "Play video message"}
className="absolute inset-0"
onClick={() => void togglePlayback()}
type="button"
/>
</div>
);
}
function AudioInlinePlayer({ src, title }: { src: string; title: string }) { function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
const track = useAudioPlayerStore((s) => s.track); const track = useAudioPlayerStore((s) => s.track);
const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying); const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);