feat: improve chat media and notification interactions
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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user