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" />
</intent-filter>
</service>
<receiver
android:name=".core.notifications.NotificationActionReceiver"
android:exported="false" />
<provider
android:name="androidx.core.content.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 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
}
}

View File

@@ -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"
}

View File

@@ -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<CircleFinalizeAction?>(null) }
var cameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(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<CameraCaptureMode?>(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<Recording?>(null) }
var activeFile by remember { mutableStateOf<File?>(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<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 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 {

View File

@@ -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<AppResult.Error>()
.firstOrNull()
?.reason
?.toUiMessage()
?: it.errorMessage,
)
}
}

View File

@@ -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(

View File

@@ -80,6 +80,9 @@
<string name="common_send">Отправить</string>
<string name="common_save">Сохранить</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_user_fallback">Пользователь</string>
<string name="profile_choose_photo">Выбрать фото</string>

View File

@@ -80,6 +80,9 @@
<string name="common_send">Send</string>
<string name="common_save">Save</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_user_fallback">User</string>
<string name="profile_choose_photo">Choose photo</string>

View File

@@ -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 (
<div className="space-y-1.5">
<button
className={`relative block overflow-hidden bg-slate-950/30 ${isCircleVideo ? "aspect-square w-56 rounded-full" : "rounded-xl"}`}
onClick={() => {
if (blockViewerOpen || isCircleVideo) {
return;
}
opts.onOpenMedia(item.url, item.type);
}}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.url);
}}
type="button"
>
{item.type === "image" ? (
<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={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>
{isInteractiveCircle ? (
<div
className="relative block aspect-square w-56 overflow-hidden rounded-full bg-slate-950/30"
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.url);
}}
>
<CircleVideoInlinePlayer src={item.url} />
</div>
) : (
<button
className={`relative block overflow-hidden bg-slate-950/30 ${isCircleVideo ? "aspect-square w-56 rounded-full" : "rounded-xl"}`}
onClick={() => {
if (blockViewerOpen || isCircleVideo) {
return;
}
opts.onOpenMedia(item.url, item.type);
}}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.url);
}}
type="button"
>
{item.type === "image" ? (
<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={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 ? (
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
) : null}
@@ -1500,6 +1513,71 @@ async function downloadFileFromUrl(url: string): Promise<void> {
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 }) {
const track = useAudioPlayerStore((s) => s.track);
const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);