diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4ba0d..578b873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` - 2026-07-13 | (без бампа — версия/релиз за лидом) | **Задачи — умные коллекции в UI (P1, tasks.diff).** Умные коллекции (Важное/Сегодня/Неделя/Все/Текущие/Завершённые) выведены в `TasksListNavView` над реальными списками: агрегат задач всех списков (`loadAllTasks`), счётчики-бейджи, тап открывает отфильтрованный вид; создание в коллекции идёт в список по умолчанию. 3-state видимость (Скрыта/Видима/Авто) в шите настроек с персистом в SharedPreferences (`TasksPrefsStore`, VM через `Factory(context)`). Движок `TasksSmartLists` расширен (`SmartListMode`/`visibleKeys`/`counts`), 25 юнит-тестов зелёные. Ff_app 16.7→24.6%, Fd_app 41.5→52.7% (`tasks.diff.md`). | контракты не менялись | низкий: UI изолирован в модуле, на устройстве не гонялось +- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: «Ответить лично» + рефактор (P1).** Пункт «Ответить лично» в меню сообщения (для чужих сообщений): `TalkViewModel.replyPrivately` открывает/создаёт 1:1 с автором через существующий `createOneToOneRoom` (OCS spreed v4, сервер идемпотентен) и `openRoom`. Заодно рефактор: блок оверлеев сообщения (лист действий + диалоги правки/удаления) вынесен из `ChatContent` в отдельный `MessageActionOverlays` — `ChatContent` LongMethod 267→174, cyclomatic 29→<25 (перестал плодить baseline-churn на каждый пункт меню). Baseline пере-привязан (сигнатура ChatContent сдвинулась от нового параметра); новый код чист (компиляция+detekt зелёные). | контракты не менялись (OCS spreed v4/v1) | низкий: изолированное UI-действие + чистый рефактор, на устройстве не гонялось - 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: напоминание (P1).** Пункт «Напоминание» в `TalkMessageActionSheet` с пресетами «Сегодня» (18:00) / «Завтра» (08:00) / «Выходные» (сб 08:00) / «Неделя» (пн 08:00): `TalkRepository.setReminder` (OCS `POST /chat/{token}/{messageId}/reminder`, timestamp сек) + `TalkViewModel.setMessageReminder`/`reminderTimestamp` + enum `ReminderPreset`. Закрывает пункт «Напоминание» дыр меню сообщения (talk.diff.md). Baseline `feature/talk` пере-привязан (сигнатура ChatContent + импорт сдвинули существующие записи; новый код чист, компиляция+detekt зелёные). | контракты не менялись (OCS spreed v1) | низкий: изолированное UI-действие, на устройстве не гонялось - 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения, P1 итерации 1:1 (talk.diff.md).** В `TalkMessageActionSheet` добавлены пункты «Копировать» (текст → буфер), «Копировать ссылку» (`TalkUrls.messageLink` = `/call/#message_` → буфер) и «Переслать в «Личные заметки»» (`TalkViewModel.forwardToNoteToSelf` — шлёт текст в комнату note-to-self, spreed type 6, через существующий OCS `/chat` v1). Закрывает часть дыр меню сообщения из `docs/app-map/talk.diff.md` (Копировать/Копировать ссылку/частично Переслать). Обновлён `feature/talk/detekt-baseline.xml` — модуль был detekt-красным ДО моей правки (устаревший baseline от чужих коммитов v0.5.133+); мои новые строки чисты (компиляция+detekt зелёные), рефреш лишь пере-привязал существующие записи к сдвинутым сигнатурам. | контракты не менялись (та же OCS /chat v1) | низкий: изолированные UI-действия в своём модуле, на устройстве не гонялось - 2026-07-13 | (без бампа — версия/релиз за лидом) | **Задачи — умные списки: проброс DTSTART.** `DavTask.startRaw` (внёс лид, core-коммит `2cb7a9a`) пробрасывается в `TaskItem.startRaw` (`toTaskItem()`) → «Текущие»/«Сегодня»/«На неделе» теперь учитывают реальный start (раньше start был пуст, «Текущие» вырождались в «все незавершённые»). Логика/тесты уже были готовы принять непустой start. | контракты не менялись | низкий diff --git a/feature/talk/detekt-baseline.xml b/feature/talk/detekt-baseline.xml index 80d8004..4f9535a 100644 --- a/feature/talk/detekt-baseline.xml +++ b/feature/talk/detekt-baseline.xml @@ -5,7 +5,6 @@ ArgumentListWrapping:TalkComponents.kt$(message.attachmentFileName!!) CyclomaticComplexMethod:TalkFormatting.kt$fun buildChatItems( messages: List<TalkMessage>, lastReadMessageId: Long, selfActorId: String, collapseSystemMessages: Boolean, ): List<TalkChatItem> CyclomaticComplexMethod:TalkScreen.kt$@Composable fun TalkScreen( session: AuthSession, modifier: Modifier = Modifier, openRoomToken: String? = null, scrollToMessageId: Long? = null, chatsListRequest: Int = 0, pushSyncRequest: Int = 0, pushRoomToken: String? = null, onOpenRoomConsumed: () -> Unit = {}, onRoomOpenStateChange: (Boolean) -> Unit = {}, onOpenCalendar: () -> Unit = {}, onUnauthorized: () -> Unit = {}, ) - CyclomaticComplexMethod:TalkScreen.kt$@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, 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, ) ImportOrdering:TalkComponents.kt$import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.border import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import okhttp3.Credentials import ru.forbion.f7cloud.core.auth.AuthSession import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import coil.compose.AsyncImage import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.layout.widthIn import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.text.DateFormat import java.util.Calendar import java.util.Date import kotlin.math.abs ImportOrdering:TalkScreen.kt$import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.launch import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7TextButton import ru.forbion.f7cloud.feature.talknative.TalkNativeCallContext import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher ImportOrdering:TalkViewModel.kt$import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.isActive import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker import ru.forbion.f7cloud.core.network.UnauthorizedException import java.util.Calendar @@ -16,7 +15,7 @@ LongMethod:TalkComponents.kt$@Composable fun TalkRoomRow( room: TalkRoom, session: AuthSession, modifier: Modifier = Modifier, onClick: () -> Unit, onToggleFavorite: () -> Unit = {}, onMarkUnread: () -> Unit = {}, ) LongMethod:TalkComponents.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TalkCreateRoomSheet( visible: Boolean, session: AuthSession, mode: CreateRoomMode, roomName: String, userSearchQuery: String, userSearchResults: List<TalkUserCandidate>, selectedUsers: List<TalkUserCandidate>, userSearchLoading: Boolean, creating: Boolean, onDismiss: () -> Unit, onModeChange: (CreateRoomMode) -> Unit, onRoomNameChange: (String) -> Unit, onUserSearchChange: (String) -> Unit, onUserToggle: (TalkUserCandidate) -> Unit, onCreate: () -> Unit, ) LongMethod:TalkScreen.kt$@Composable fun TalkScreen( session: AuthSession, modifier: Modifier = Modifier, openRoomToken: String? = null, scrollToMessageId: Long? = null, chatsListRequest: Int = 0, pushSyncRequest: Int = 0, pushRoomToken: String? = null, onOpenRoomConsumed: () -> Unit = {}, onRoomOpenStateChange: (Boolean) -> Unit = {}, onOpenCalendar: () -> Unit = {}, onUnauthorized: () -> Unit = {}, ) - LongMethod:TalkScreen.kt$@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, 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, ) + LongMethod:TalkScreen.kt$@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, ) LoopWithTooManyJumpStatements:TalkRepository.kt$TalkRepository$for LoopWithTooManyJumpStatements:TalkViewModel.kt$TalkViewModel$while MatchingDeclarationName:TalkFilters.kt$TalkRoomFilter diff --git a/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkComponents.kt b/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkComponents.kt index 7592f59..2df3cc7 100644 --- a/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkComponents.kt +++ b/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkComponents.kt @@ -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) }) diff --git a/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkScreen.kt b/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkScreen.kt index fe9d96f..6c3e910 100644 --- a/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkScreen.kt +++ b/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkScreen.kt @@ -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(null) } - var pendingDelete by remember { mutableStateOf(null) } - var editingMessage by remember { mutableStateOf(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(null) } + var editDraft by remember { mutableStateOf("") } + var pendingDelete by remember { mutableStateOf(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(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( diff --git a/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkViewModel.kt b/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkViewModel.kt index fe90e4f..ff4680e 100644 --- a/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkViewModel.kt +++ b/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkViewModel.kt @@ -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