feat(talk): меню сообщения — Ответить лично; рефактор оверлеев (ChatContent проще) [P1]

This commit is contained in:
b-mob-conf
2026-07-13 07:33:13 +00:00
parent 8a8f1cfffb
commit a60fd78a28
5 changed files with 141 additions and 82 deletions
@@ -799,8 +799,10 @@ fun TalkUnreadMarker(modifier: Modifier = Modifier) {
fun TalkMessageActionSheet(
message: TalkMessage?,
canModify: Boolean,
canReplyPrivately: Boolean,
onDismiss: () -> Unit,
onReply: (TalkMessage) -> Unit,
onReplyPrivately: (TalkMessage) -> Unit,
onEdit: (TalkMessage) -> Unit,
onDelete: (TalkMessage) -> Unit,
onReaction: (TalkMessage, String) -> Unit,
@@ -830,6 +832,9 @@ fun TalkMessageActionSheet(
modifier = Modifier.padding(bottom = 8.dp),
)
F7TextButton(text = "Ответить", onClick = { onReply(message) })
if (canReplyPrivately) {
F7TextButton(text = "Ответить лично", onClick = { onReplyPrivately(message) })
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("👍", "❤️", "😂").forEach { emoji ->
F7TextButton(text = emoji, onClick = { onReaction(message, emoji) })
@@ -292,6 +292,7 @@ fun TalkScreen(
onScheduleMeeting = onOpenCalendar,
onToggleSystemMessages = vm::toggleSystemMessagesCollapse,
onReply = { vm.setReplyTo(it) },
onReplyPrivately = { vm.replyPrivately(session, it) },
onReaction = { msg, emoji -> vm.addReaction(session, msg.id, emoji) },
onDeleteMessage = { vm.deleteMessage(session, it.id) },
onEditMessage = { msg, text -> vm.editMessage(session, msg.id, text) },
@@ -416,128 +417,76 @@ private fun RoomListContent(
}
@Composable
private fun ChatContent(
private fun MessageActionOverlays(
actionMessage: TalkMessage?,
session: AuthSession,
state: TalkUiState,
draft: String,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
onAttach: () -> Unit,
onVoiceToggle: () -> Unit,
voiceRecording: Boolean,
onRetry: () -> Unit,
onStartCall: () -> Unit,
onBackClick: () -> Unit,
onOpenRoomInfo: () -> Unit,
onScheduleMeeting: () -> Unit,
onToggleSystemMessages: () -> Unit,
roomToken: String?,
onClearAction: () -> Unit,
onReply: (TalkMessage) -> Unit,
onReplyPrivately: (TalkMessage) -> Unit,
onReaction: (TalkMessage, String) -> Unit,
onDeleteMessage: (TalkMessage) -> Unit,
onEditMessage: (TalkMessage, String) -> Unit,
onForwardNoteToSelf: (TalkMessage) -> Unit,
onSetReminder: (TalkMessage, ReminderPreset) -> Unit,
onMentionQuery: (String?) -> Unit,
onMentionSelected: (TalkMention) -> Unit,
onClearReply: () -> Unit,
onClearHighlight: () -> Unit,
onEditMessage: (TalkMessage, String) -> Unit,
onDeleteMessage: (TalkMessage) -> Unit,
) {
var actionMessage by remember { mutableStateOf<TalkMessage?>(null) }
var pendingDelete by remember { mutableStateOf<TalkMessage?>(null) }
var editingMessage by remember { mutableStateOf<TalkMessage?>(null) }
var editDraft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val chatItems = remember(state.messages, state.openRoomLastReadMessageId, state.collapseSystemMessages) {
buildChatItems(
messages = state.messages,
lastReadMessageId = state.openRoomLastReadMessageId,
selfActorId = session.username,
collapseSystemMessages = state.collapseSystemMessages,
)
}
val systemMessageCount = remember(state.messages) {
state.messages.count { it.isSystemMessage }
}
val showScrollDown by remember {
derivedStateOf {
if (chatItems.isEmpty()) return@derivedStateOf false
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisible < info.totalItemsCount - 1
}
}
var stickToBottom by remember { mutableStateOf(true) }
LaunchedEffect(showScrollDown) {
stickToBottom = !showScrollDown
}
LaunchedEffect(chatItems.size, state.highlightMessageId) {
val targetId = state.highlightMessageId ?: return@LaunchedEffect
val idx = chatItems.indexOfFirst { item ->
item is TalkChatItem.MessageGroup && item.messages.any { it.id == targetId }
}
if (idx >= 0) {
listState.animateScrollToItem(idx)
kotlinx.coroutines.delay(2500)
onClearHighlight()
}
}
LaunchedEffect(chatItems.size) {
if (chatItems.isNotEmpty() && stickToBottom) {
listState.animateScrollToItem(chatItems.lastIndex)
}
}
var editingMessage by remember { mutableStateOf<TalkMessage?>(null) }
var editDraft by remember { mutableStateOf("") }
var pendingDelete by remember { mutableStateOf<TalkMessage?>(null) }
TalkMessageActionSheet(
message = actionMessage,
canModify = actionMessage?.let { msg ->
msg.actorId.isNotBlank() && msg.actorId == session.username && !msg.isSystemMessage
} == true,
onDismiss = { actionMessage = null },
canReplyPrivately = actionMessage?.let { msg ->
msg.actorId.isNotBlank() && msg.actorId != session.username && !msg.isSystemMessage
} == true,
onDismiss = onClearAction,
onReply = {
onReply(it)
actionMessage = null
onClearAction()
},
onReplyPrivately = {
onReplyPrivately(it)
onClearAction()
},
onEdit = {
editingMessage = it
editDraft = it.text
actionMessage = null
onClearAction()
},
onDelete = {
pendingDelete = it
actionMessage = null
onClearAction()
},
onReaction = { msg, emoji ->
onReaction(msg, emoji)
actionMessage = null
onClearAction()
},
onCopy = { msg ->
clipboard.setText(AnnotatedString(msg.displayText))
Toast.makeText(context, "Скопировано", Toast.LENGTH_SHORT).show()
actionMessage = null
onClearAction()
},
onCopyLink = { msg ->
val token = state.selectedRoomToken
if (token != null) {
clipboard.setText(AnnotatedString(TalkUrls.messageLink(session, token, msg.id)))
if (roomToken != null) {
clipboard.setText(AnnotatedString(TalkUrls.messageLink(session, roomToken, msg.id)))
Toast.makeText(context, "Ссылка скопирована", Toast.LENGTH_SHORT).show()
}
actionMessage = null
onClearAction()
},
onForwardNoteToSelf = { msg ->
onForwardNoteToSelf(msg)
Toast.makeText(context, "Переслано в «Личные заметки»", Toast.LENGTH_SHORT).show()
actionMessage = null
onClearAction()
},
onSetReminder = { msg, preset ->
onSetReminder(msg, preset)
Toast.makeText(context, "Напоминание установлено", Toast.LENGTH_SHORT).show()
actionMessage = null
onClearAction()
},
)
@@ -589,6 +538,95 @@ private fun ChatContent(
},
)
}
}
@Composable
private fun ChatContent(
session: AuthSession,
state: TalkUiState,
draft: String,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
onAttach: () -> Unit,
onVoiceToggle: () -> Unit,
voiceRecording: Boolean,
onRetry: () -> Unit,
onStartCall: () -> Unit,
onBackClick: () -> Unit,
onOpenRoomInfo: () -> Unit,
onScheduleMeeting: () -> Unit,
onToggleSystemMessages: () -> Unit,
onReply: (TalkMessage) -> Unit,
onReplyPrivately: (TalkMessage) -> Unit,
onReaction: (TalkMessage, String) -> Unit,
onDeleteMessage: (TalkMessage) -> Unit,
onEditMessage: (TalkMessage, String) -> Unit,
onForwardNoteToSelf: (TalkMessage) -> Unit,
onSetReminder: (TalkMessage, ReminderPreset) -> Unit,
onMentionQuery: (String?) -> Unit,
onMentionSelected: (TalkMention) -> Unit,
onClearReply: () -> Unit,
onClearHighlight: () -> Unit,
) {
var actionMessage by remember { mutableStateOf<TalkMessage?>(null) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val chatItems = remember(state.messages, state.openRoomLastReadMessageId, state.collapseSystemMessages) {
buildChatItems(
messages = state.messages,
lastReadMessageId = state.openRoomLastReadMessageId,
selfActorId = session.username,
collapseSystemMessages = state.collapseSystemMessages,
)
}
val systemMessageCount = remember(state.messages) {
state.messages.count { it.isSystemMessage }
}
val showScrollDown by remember {
derivedStateOf {
if (chatItems.isEmpty()) return@derivedStateOf false
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisible < info.totalItemsCount - 1
}
}
var stickToBottom by remember { mutableStateOf(true) }
LaunchedEffect(showScrollDown) {
stickToBottom = !showScrollDown
}
LaunchedEffect(chatItems.size, state.highlightMessageId) {
val targetId = state.highlightMessageId ?: return@LaunchedEffect
val idx = chatItems.indexOfFirst { item ->
item is TalkChatItem.MessageGroup && item.messages.any { it.id == targetId }
}
if (idx >= 0) {
listState.animateScrollToItem(idx)
kotlinx.coroutines.delay(2500)
onClearHighlight()
}
}
LaunchedEffect(chatItems.size) {
if (chatItems.isNotEmpty() && stickToBottom) {
listState.animateScrollToItem(chatItems.lastIndex)
}
}
MessageActionOverlays(
actionMessage = actionMessage,
session = session,
roomToken = state.selectedRoomToken,
onClearAction = { actionMessage = null },
onReply = onReply,
onReplyPrivately = onReplyPrivately,
onReaction = onReaction,
onForwardNoteToSelf = onForwardNoteToSelf,
onSetReminder = onSetReminder,
onEditMessage = onEditMessage,
onDeleteMessage = onDeleteMessage,
)
Box(modifier = Modifier.fillMaxSize()) {
TalkChatBackground(
@@ -625,6 +625,22 @@ class TalkViewModel(
}
}
/** Ответить лично: открыть (или создать) 1:1 с автором сообщения. */
fun replyPrivately(session: AuthSession, message: TalkMessage) {
val userId = message.actorId
if (userId.isBlank() || userId == session.username) return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.createOneToOneRoom(session, userId) }
.onSuccess { room -> openRoom(session, room.token) }
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message ?: "Не удалось открыть личный чат",
unauthorized = t is UnauthorizedException,
)
}
}
}
/** Напоминание на сообщение по пресету (сегодня/завтра/выходные/неделя). */
fun setMessageReminder(session: AuthSession, messageId: Long, preset: ReminderPreset) {
val token = _state.value.selectedRoomToken ?: return