From 01198a4d148b0e3cb844da3c33404d5c5ece1ac5 Mon Sep 17 00:00:00 2001 From: b-mob-conf Date: Mon, 13 Jul 2026 07:39:48 +0000 Subject: [PATCH] =?UTF-8?q?feat(talk):=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=80=94=20?= =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=81=D0=BB=D0=B0=D1=82=D1=8C=E2=80=A6?= =?UTF-8?q?=20=D0=B2=20=D0=BB=D1=8E=D0=B1=D1=83=D1=8E=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BD=D0=B0=D1=82=D1=83=20(=D0=BF=D0=B8=D0=BA=D0=B5=D1=80)=20[?= =?UTF-8?q?P1]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + feature/talk/detekt-baseline.xml | 2 +- .../f7cloud/feature/talk/TalkComponents.kt | 35 +++++++++ .../f7cloud/feature/talk/TalkScreen.kt | 74 ++++++++++++++----- .../f7cloud/feature/talk/TalkViewModel.kt | 15 ++++ 5 files changed, 106 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a24bf1..cf26055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — ядро правки/удаления (P1, contacts.diff, шаг 1/2).** `CardDavClient`: `updateContact` (PUT If-Match по ETag, round-trip неизвестных свойств vCard — PHOTO/UID/X-* сохраняются) и `deleteContact` (DELETE If-Match, 404=уже удалён, 412=конфликт ETag). `DavContact` +`href`/`etag`/`raw` (заполняются чтением книги; сигнатуры list/create НЕ менялись). `parseContacts` теперь ловит href/getetag на каждый ``; общий `davContactFromVCard`. Чистый движок `VCardEditor.applyEdits` (замена управляемых FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE, фолдинг 75, CRLF, экранирование) + модели `VCardFields`/`VCardTypedValue`. **10 юнит-тестов** (`VCardEditorTest`) зелёные: round-trip неизвестных полей, FN с запятыми, PHOTO base64, folded ADR, CRLF-фолдинг. Гейт `:app:compileReleaseKotlin` зелёный. Шаг 2/2 (Room href/etag/raw + миграция, форма правки в карточке, delete в UI) — следующим коммитом. | контракты: core `CardDavClient` +2 метода, `DavContact` +3 опц. поля (обратно совместимо, list/create не тронуты); одобрено лидом mail/072 | низкий: только добавление методов + поля с дефолтами, синк не задет; на устройстве/сервере ещё не гонялось (юниты + компил-гейт) - 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).** Пункт «Переслать…» открывает лист выбора комнаты (`ForwardTargetSheet` — список бесед кроме текущей) → `TalkViewModel.forwardToRoom` шлёт текст в выбранную (OCS `/chat` v1). Диалог удаления вынесен в `ConfirmDeleteMessageDialog` (держит новый `MessageActionOverlays` <120 строк). Завершает историю пересылки (в «Личные заметки» + в произвольную комнату). Baseline пере-привязан (сигнатура ChatContent от нового параметра). | контракты не менялись (OCS /chat v1) | низкий: 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-действия в своём модуле, на устройстве не гонялось diff --git a/feature/talk/detekt-baseline.xml b/feature/talk/detekt-baseline.xml index 4f9535a..9794bb6 100644 --- a/feature/talk/detekt-baseline.xml +++ b/feature/talk/detekt-baseline.xml @@ -15,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, 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, ) + 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, onForwardToRoom: (TalkMessage, String) -> 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 2df3cc7..7502058 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 @@ -794,6 +794,39 @@ fun TalkUnreadMarker(modifier: Modifier = Modifier) { } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ForwardTargetSheet( + message: TalkMessage?, + rooms: List, + onDismiss: () -> Unit, + onPick: (TalkMessage, TalkRoom) -> Unit, +) { + if (message == null) return + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = F7Colors.Surface) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "Переслать в…", + style = MaterialTheme.typography.labelMedium, + color = F7Colors.TextSecondary, + ) + LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) { + items(rooms, key = { it.token }) { room -> + F7TextButton(text = room.displayName, onClick = { onPick(message, room) }) + } + } + F7TextButton(text = "Отмена", onClick = onDismiss) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun TalkMessageActionSheet( @@ -809,6 +842,7 @@ fun TalkMessageActionSheet( onCopy: (TalkMessage) -> Unit, onCopyLink: (TalkMessage) -> Unit, onForwardNoteToSelf: (TalkMessage) -> Unit, + onForward: (TalkMessage) -> Unit, onSetReminder: (TalkMessage, ReminderPreset) -> Unit, ) { if (message == null) return @@ -847,6 +881,7 @@ fun TalkMessageActionSheet( text = "Переслать в «Личные заметки»", onClick = { onForwardNoteToSelf(message) }, ) + F7TextButton(text = "Переслать…", onClick = { onForward(message) }) Text( "Напоминание:", style = MaterialTheme.typography.labelMedium, 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 6c3e910..39fe3f1 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 @@ -297,6 +297,7 @@ fun TalkScreen( onDeleteMessage = { vm.deleteMessage(session, it.id) }, onEditMessage = { msg, text -> vm.editMessage(session, msg.id, text) }, onForwardNoteToSelf = { vm.forwardToNoteToSelf(session, it) }, + onForwardToRoom = { msg, token -> vm.forwardToRoom(session, msg, token) }, onSetReminder = { msg, preset -> vm.setMessageReminder(session, msg.id, preset) }, onMentionQuery = { vm.setMentionQuery(session, it) }, onMentionSelected = { mention -> @@ -416,16 +417,42 @@ private fun RoomListContent( } } +@Composable +private fun ConfirmDeleteMessageDialog( + message: TalkMessage?, + onConfirm: (TalkMessage) -> Unit, + onDismiss: () -> Unit, +) { + if (message == null) return + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Удалить сообщение?") }, + text = { Text("Сообщение будет удалено для всех участников.") }, + confirmButton = { + TextButton(onClick = { onConfirm(message) }) { + Text("Удалить", color = F7Colors.Primary) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Отмена") + } + }, + ) +} + @Composable private fun MessageActionOverlays( actionMessage: TalkMessage?, session: AuthSession, roomToken: String?, + rooms: List, onClearAction: () -> Unit, onReply: (TalkMessage) -> Unit, onReplyPrivately: (TalkMessage) -> Unit, onReaction: (TalkMessage, String) -> Unit, onForwardNoteToSelf: (TalkMessage) -> Unit, + onForwardToRoom: (TalkMessage, String) -> Unit, onSetReminder: (TalkMessage, ReminderPreset) -> Unit, onEditMessage: (TalkMessage, String) -> Unit, onDeleteMessage: (TalkMessage) -> Unit, @@ -435,6 +462,7 @@ private fun MessageActionOverlays( var editingMessage by remember { mutableStateOf(null) } var editDraft by remember { mutableStateOf("") } var pendingDelete by remember { mutableStateOf(null) } + var forwardMessage by remember { mutableStateOf(null) } TalkMessageActionSheet( message = actionMessage, @@ -483,6 +511,10 @@ private fun MessageActionOverlays( Toast.makeText(context, "Переслано в «Личные заметки»", Toast.LENGTH_SHORT).show() onClearAction() }, + onForward = { msg -> + forwardMessage = msg + onClearAction() + }, onSetReminder = { msg, preset -> onSetReminder(msg, preset) Toast.makeText(context, "Напоминание установлено", Toast.LENGTH_SHORT).show() @@ -518,26 +550,25 @@ private fun MessageActionOverlays( ) } - pendingDelete?.let { message -> - AlertDialog( - onDismissRequest = { pendingDelete = null }, - title = { Text("Удалить сообщение?") }, - text = { Text("Сообщение будет удалено для всех участников.") }, - confirmButton = { - TextButton(onClick = { - onDeleteMessage(message) - pendingDelete = null - }) { - Text("Удалить", color = F7Colors.Primary) - } - }, - dismissButton = { - TextButton(onClick = { pendingDelete = null }) { - Text("Отмена") - } - }, - ) - } + ConfirmDeleteMessageDialog( + message = pendingDelete, + onConfirm = { + onDeleteMessage(it) + pendingDelete = null + }, + onDismiss = { pendingDelete = null }, + ) + + ForwardTargetSheet( + message = forwardMessage, + rooms = rooms.filter { it.token != roomToken }, + onDismiss = { forwardMessage = null }, + onPick = { msg, room -> + onForwardToRoom(msg, room.token) + forwardMessage = null + Toast.makeText(context, "Переслано", Toast.LENGTH_SHORT).show() + }, + ) } @Composable @@ -562,6 +593,7 @@ private fun ChatContent( onDeleteMessage: (TalkMessage) -> Unit, onEditMessage: (TalkMessage, String) -> Unit, onForwardNoteToSelf: (TalkMessage) -> Unit, + onForwardToRoom: (TalkMessage, String) -> Unit, onSetReminder: (TalkMessage, ReminderPreset) -> Unit, onMentionQuery: (String?) -> Unit, onMentionSelected: (TalkMention) -> Unit, @@ -618,11 +650,13 @@ private fun ChatContent( actionMessage = actionMessage, session = session, roomToken = state.selectedRoomToken, + rooms = state.rooms, onClearAction = { actionMessage = null }, onReply = onReply, onReplyPrivately = onReplyPrivately, onReaction = onReaction, onForwardNoteToSelf = onForwardNoteToSelf, + onForwardToRoom = onForwardToRoom, onSetReminder = onSetReminder, onEditMessage = onEditMessage, onDeleteMessage = onDeleteMessage, 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 ff4680e..790fad9 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,21 @@ class TalkViewModel( } } + /** Переслать текст сообщения в выбранную комнату. */ + fun forwardToRoom(session: AuthSession, message: TalkMessage, targetToken: String) { + val text = message.displayText.trim() + if (text.isEmpty() || targetToken.isBlank()) return + viewModelScope.launch(Dispatchers.IO) { + runCatching { repository.sendMessage(session, targetToken, text) } + .onFailure { t -> + _state.value = _state.value.copy( + error = t.message ?: "Не удалось переслать", + unauthorized = t is UnauthorizedException, + ) + } + } + } + /** Ответить лично: открыть (или создать) 1:1 с автором сообщения. */ fun replyPrivately(session: AuthSession, message: TalkMessage) { val userId = message.actorId