From 0636ef5fa95c03b935bde2cdec254e6163e5f646 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 8 Sep 2026 23:00:08 +0300 Subject: [PATCH] =?UTF-8?q?:zap:=20perf(conversations):=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D0=B8=20=D0=B8=D0=BD=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA=D1=81=20=D0=B3=D1=80=D1=83=D0=B7=D1=8F=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BE=D0=BA=D0=BD=D0=B0=D0=BC=D0=B8,=20=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=86=D0=B5=D0=BB=D0=B8=D0=BA=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Открытие диалога тянуло все сообщения разом: карточка несла messages без ограничения, а workspace перезапрашивал её каждые 3 секунды — тысяча реплик уезжала клиенту двадцать раз в минуту. Список диалогов приходил так же целиком и обновлялся раз в 4 секунды. Серверная часть: - api/pagination: единый контракт — страницы (page/pageSize/total/pageCount) и окно по курсору (keyset) с потолками размера; - карточка диалога больше не несёт сообщений ни в одном режиме; факт запроса контакта считает сервер, ленте для этого история не нужна; - GET /conversations//messages/ — окно истории: before — вверх по ленте, after — дельта после последнего показанного сообщения; - инбокс отдаётся окном по курсору с серверной сортировкой (активность или «ждущие первыми») и total по текущим фильтрам. Клиент: - useConversationHistory: хвост переписки при открытии, догрузка вверх, обновление дельтой вместо перезапроса всей ленты; - useHistoryScroll: лента держит низ, подгружает старые сообщения у верхней границы и сохраняет место чтения при вклейке; - useConversationList: окно инбокса, догрузка прокруткой, обновление головы списка без сброса прокрутки; - поиск, вкладка и сортировка списка переехали в параметры запроса — фильтровать окно в браузере было бы неправильно; - клавиатурная навигация вынесена в useDialogKeyboardNav. Co-Authored-By: Claude Opus 5 --- apps/backend/chatballs/api/pagination.py | 207 +++++++++++++++++ apps/backend/chatballs/api/test_pagination.py | 123 +++++++++++ .../conversations/chat_extras_views.py | 10 +- .../chatballs/conversations/selectors.py | 84 ++++++- .../chatballs/conversations/serializers.py | 28 ++- .../conversations/test_attachments.py | 6 +- .../conversations/test_email_transport.py | 10 +- .../conversations/test_history_window.py | 208 ++++++++++++++++++ apps/backend/chatballs/conversations/views.py | 105 +++++++-- .../conversations/ConversationThread.tsx | 28 ++- .../conversations/ConversationWorkspace.tsx | 128 ++++------- .../src/features/conversations/DialogList.tsx | 36 ++- .../src/features/conversations/model.ts | 58 +++-- .../src/features/conversations/types.ts | 3 + .../conversations/useConversationHistory.ts | 116 ++++++++++ .../conversations/useConversationList.test.ts | 26 +++ .../conversations/useConversationList.ts | 123 +++++++++++ .../conversations/useDialogKeyboardNav.ts | 47 ++++ .../conversations/useHistoryScroll.ts | 76 +++++++ .../sales/dialogs/context/ClientContext.tsx | 5 +- 20 files changed, 1264 insertions(+), 163 deletions(-) create mode 100644 apps/backend/chatballs/api/pagination.py create mode 100644 apps/backend/chatballs/api/test_pagination.py create mode 100644 apps/backend/chatballs/conversations/test_history_window.py create mode 100644 apps/internal-ui/src/features/conversations/useConversationHistory.ts create mode 100644 apps/internal-ui/src/features/conversations/useConversationList.test.ts create mode 100644 apps/internal-ui/src/features/conversations/useConversationList.ts create mode 100644 apps/internal-ui/src/features/conversations/useDialogKeyboardNav.ts create mode 100644 apps/internal-ui/src/features/conversations/useHistoryScroll.ts diff --git a/apps/backend/chatballs/api/pagination.py b/apps/backend/chatballs/api/pagination.py new file mode 100644 index 0000000..d40b4ef --- /dev/null +++ b/apps/backend/chatballs/api/pagination.py @@ -0,0 +1,207 @@ +"""Единый контракт постраничных и оконных ответов API. + +Режима два, и оба серверные — ни один список не отдаётся целиком: + +* **Страницы** (`paginate`) — списки с номерами страниц в интерфейсе: порталы, + библиотека статей, контакты, сотрудники, агенты. Ответ несёт `page`, + `pageSize`, `total`, `pageCount`, поэтому подвал со страницами рисуется без + догадок о размере набора. +* **Окно по курсору** (`window`) — живые ленты: список диалогов и история + сообщений. Номера страниц там бессмысленны: новая запись приходит сверху и + сдвигает нумерацию, из-за чего offset выдаёт дубли и пропуски. Курсор — + идентификатор граничной записи; сравнение строится по тому же порядку + сортировки, что и сам список (keyset pagination). + +Оба режима отдают на один элемент больше запрошенного только внутри окна — +наружу уходит ровно `limit`, а лишний элемент превращается в `hasMore`. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any + +from django.db.models import Q, QuerySet +from rest_framework.exceptions import ValidationError + +DEFAULT_PAGE_SIZE = 20 +MAX_PAGE_SIZE = 100 +DEFAULT_WINDOW_SIZE = 50 +MAX_WINDOW_SIZE = 200 +# Верхняя граница номера страницы: защита от `?page=99999999`, где OFFSET +# заставит базу пройти весь индекс ради пустого ответа. +MAX_PAGE_NUMBER = 100_000 + + +def _bounded_int(params, name: str, default: int, maximum: int) -> int: + raw = params.get(name) + if raw in (None, ""): + return default + try: + value = int(raw) + except (TypeError, ValueError): + raise ValidationError(f"{name}: ожидается целое число") from None + if value < 1: + raise ValidationError(f"{name}: ожидается число больше нуля") + return min(value, maximum) + + +def cursor_id(params, name: str = "cursor") -> int | None: + """Курсор окна — id граничной записи. Пустой параметр означает начало ленты.""" + raw = params.get(name) + if raw in (None, ""): + return None + try: + value = int(raw) + except (TypeError, ValueError): + raise ValidationError(f"{name}: ожидается идентификатор записи") from None + if value < 1: + raise ValidationError(f"{name}: ожидается идентификатор записи") + return value + + +@dataclass(frozen=True) +class Page: + """Одна страница набора: сами записи и всё, что нужно подвалу со страницами.""" + + items: list[Any] + page: int + page_size: int + total: int + + @property + def page_count(self) -> int: + return max(1, -(-self.total // self.page_size)) + + +def paginate( + queryset: QuerySet, + params, + *, + default_size: int = DEFAULT_PAGE_SIZE, + max_size: int = MAX_PAGE_SIZE, +) -> Page: + """Страница набора по `?page=&pageSize=`. + + Запрос страницы за пределами набора возвращает последнюю существующую: так + список не становится пустым, когда записи удалили из-под открытой страницы. + """ + page_size = _bounded_int(params, "pageSize", default_size, max_size) + requested = _bounded_int(params, "page", 1, MAX_PAGE_NUMBER) + total = queryset.count() + page_count = max(1, -(-total // page_size)) + page = min(requested, page_count) + start = (page - 1) * page_size + return Page( + items=list(queryset[start : start + page_size]), + page=page, + page_size=page_size, + total=total, + ) + + +def page_payload(page: Page, serialize: Callable[[Any], dict]) -> dict[str, object]: + return { + "items": [serialize(item) for item in page.items], + "page": page.page, + "pageSize": page.page_size, + "total": page.total, + "pageCount": page.page_count, + } + + +@dataclass(frozen=True) +class SortKey: + """Поле сортировки окна: имя поля или annotation и направление. + + Поле обязано быть непустым (NOT NULL или Coalesce): сравнение курсора по + NULL в SQL не даёт истины, и окно молча теряло бы записи. + """ + + field: str + descending: bool = True + + @property + def ordering(self) -> str: + return f"-{self.field}" if self.descending else self.field + + @property + def lookup(self) -> str: + return f"{self.field}__{'lt' if self.descending else 'gt'}" + + +@dataclass(frozen=True) +class Window: + """Окно ленты: записи в порядке сортировки и курсор на продолжение.""" + + items: list[Any] + has_more: bool + + @property + def cursor(self) -> int | None: + return self.items[-1].pk if self.items and self.has_more else None + + +def window_payload( + win: Window, serialize: Callable[[Any], dict], *, total: int | None = None +) -> dict[str, object]: + """Тело ответа-окна. `total` добавляется там, где интерфейс показывает + размер всего набора (счётчик над списком), и стоит одного COUNT.""" + payload: dict[str, object] = { + "items": [serialize(item) for item in win.items], + "hasMore": win.has_more, + "cursor": win.cursor, + } + if total is not None: + payload["total"] = total + return payload + + +def _after_cursor(queryset: QuerySet, keys: Sequence[SortKey], anchor_id: int) -> Q | None: + """Условие «строго после записи anchor_id» в порядке keys. + + Значения ключей берутся из того же queryset, поэтому annotation-поля + (например, время последнего сообщения) доступны наравне с обычными. + """ + fields = [key.field for key in keys] + anchor = queryset.filter(pk=anchor_id).values(*fields).first() + if anchor is None: + # Курсор указывает на запись, которой в наборе больше нет (её удалили, + # закрыли или она ушла под фильтр). Отдать начало ленты вместо пустоты. + return None + condition = Q() + equal_prefix = Q() + for key in keys: + condition |= equal_prefix & Q(**{key.lookup: anchor[key.field]}) + equal_prefix &= Q(**{key.field: anchor[key.field]}) + return condition + + +def window( + queryset: QuerySet, + *, + keys: Sequence[SortKey], + limit: int, + after: int | None = None, +) -> Window: + """Окно записей по курсору. + + `keys` задаёт и порядок, и правило сравнения; последним ключом должен идти + уникальный столбец (обычно `id`), иначе записи с одинаковым временем будут + выпадать из ленты или повторяться на границе окна. + """ + if not keys: + raise ValueError("окно требует хотя бы один ключ сортировки") + ordered = queryset.order_by(*[key.ordering for key in keys]) + if after is not None: + condition = _after_cursor(ordered, keys, after) + if condition is not None: + ordered = ordered.filter(condition) + items = list(ordered[: limit + 1]) + has_more = len(items) > limit + return Window(items=items[:limit], has_more=has_more) + + +def window_size(params, *, default: int = DEFAULT_WINDOW_SIZE, name: str = "limit") -> int: + return _bounded_int(params, name, default, MAX_WINDOW_SIZE) diff --git a/apps/backend/chatballs/api/test_pagination.py b/apps/backend/chatballs/api/test_pagination.py new file mode 100644 index 0000000..1c2ff0f --- /dev/null +++ b/apps/backend/chatballs/api/test_pagination.py @@ -0,0 +1,123 @@ +"""Контракт постраничных ответов: границы, потолки и поведение на краю набора.""" + +from django.http import QueryDict +from django.test import TestCase +from rest_framework.exceptions import ValidationError + +from chatballs.api.pagination import ( + MAX_PAGE_SIZE, + SortKey, + cursor_id, + page_payload, + paginate, + window, + window_size, +) +from chatballs.conversations.models import Contact +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.identity.models import Organization + + +def params(query: str = "") -> QueryDict: + return QueryDict(query) + + +class PaginateTests(TestCase): + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + for index in range(45): + Contact.objects.create( + organization=self.organization, name=f"Контакт {index:02d}" + ) + self.queryset = Contact.objects.filter( + organization=self.organization + ).order_by("name") + + def test_first_page_reports_whole_set(self) -> None: + page = paginate(self.queryset, params("pageSize=20")) + self.assertEqual(len(page.items), 20) + self.assertEqual(page.page, 1) + self.assertEqual(page.total, 45) + self.assertEqual(page.page_count, 3) + + def test_last_page_holds_remainder(self) -> None: + page = paginate(self.queryset, params("page=3&pageSize=20")) + self.assertEqual(len(page.items), 5) + + def test_page_beyond_set_falls_back_to_last(self) -> None: + # Записи могли удалить из-под открытой страницы — список не должен + # становиться пустым. + page = paginate(self.queryset, params("page=99&pageSize=20")) + self.assertEqual(page.page, 3) + self.assertEqual(len(page.items), 5) + + def test_page_size_is_capped(self) -> None: + page = paginate(self.queryset, params(f"pageSize={MAX_PAGE_SIZE * 10}")) + self.assertEqual(page.page_size, MAX_PAGE_SIZE) + + def test_empty_set_still_has_one_page(self) -> None: + page = paginate(Contact.objects.none(), params()) + self.assertEqual(page.page_count, 1) + self.assertEqual(page.total, 0) + + def test_broken_params_are_rejected(self) -> None: + for query in ("page=abc", "page=0", "pageSize=-1", "pageSize=x"): + with self.subTest(query=query): + with self.assertRaises(ValidationError): + paginate(self.queryset, params(query)) + + def test_payload_carries_everything_the_footer_needs(self) -> None: + payload = page_payload( + paginate(self.queryset, params("pageSize=20")), lambda c: {"id": c.id} + ) + self.assertEqual( + set(payload), {"items", "page", "pageSize", "total", "pageCount"} + ) + + +class WindowTests(TestCase): + """Окно по курсору: без дублей и пропусков на границах.""" + + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + self.contacts = [ + Contact.objects.create( + organization=self.organization, name=f"Контакт {index:02d}" + ) + for index in range(25) + ] + self.queryset = Contact.objects.filter(organization=self.organization) + self.keys = (SortKey("id"),) + + def test_window_reports_more(self) -> None: + page = window(self.queryset, keys=self.keys, limit=10) + self.assertEqual(len(page.items), 10) + self.assertTrue(page.has_more) + self.assertEqual(page.cursor, page.items[-1].id) + + def test_cursor_walks_whole_set(self) -> None: + seen: list[int] = [] + page = window(self.queryset, keys=self.keys, limit=10) + seen += [item.id for item in page.items] + while page.has_more: + page = window(self.queryset, keys=self.keys, limit=10, after=page.cursor) + seen += [item.id for item in page.items] + self.assertEqual(seen, sorted((c.id for c in self.contacts), reverse=True)) + + def test_last_window_has_no_cursor(self) -> None: + page = window(self.queryset, keys=self.keys, limit=100) + self.assertFalse(page.has_more) + self.assertIsNone(page.cursor) + + def test_missing_anchor_returns_head(self) -> None: + page = window(self.queryset, keys=self.keys, limit=5, after=10_000_000) + self.assertEqual(len(page.items), 5) + + def test_window_size_and_cursor_params_are_validated(self) -> None: + self.assertEqual(window_size(params("limit=7")), 7) + self.assertIsNone(cursor_id(params())) + self.assertEqual(cursor_id(params("cursor=12")), 12) + with self.assertRaises(ValidationError): + cursor_id(params("cursor=abc")) diff --git a/apps/backend/chatballs/conversations/chat_extras_views.py b/apps/backend/chatballs/conversations/chat_extras_views.py index 4ec2720..c51a781 100644 --- a/apps/backend/chatballs/conversations/chat_extras_views.py +++ b/apps/backend/chatballs/conversations/chat_extras_views.py @@ -63,7 +63,7 @@ class ConversationPriorityView(ConversationViewBase): return Response( { "conversation": conversation_payload( - conversation, with_messages=True, viewer_id=request.user.id + conversation, detailed=True, viewer_id=request.user.id ) } ) @@ -103,7 +103,7 @@ class ConversationContactView(ConversationViewBase): return Response( { "conversation": conversation_payload( - conversation, with_messages=True, viewer_id=request.user.id + conversation, detailed=True, viewer_id=request.user.id ) } ) @@ -129,7 +129,7 @@ class ConversationNoteView(ConversationViewBase): return Response( { "conversation": conversation_payload( - conversation, with_messages=True, viewer_id=request.user.id + conversation, detailed=True, viewer_id=request.user.id ) } ) @@ -164,7 +164,7 @@ class ConversationLabelsView(ConversationViewBase): return Response( { "conversation": conversation_payload( - conversation, with_messages=True, viewer_id=request.user.id + conversation, detailed=True, viewer_id=request.user.id ) } ) @@ -196,7 +196,7 @@ class ConversationArchiveView(ConversationViewBase): return Response( { "conversation": conversation_payload( - conversation, with_messages=True, viewer_id=request.user.id + conversation, detailed=True, viewer_id=request.user.id ) } ) diff --git a/apps/backend/chatballs/conversations/selectors.py b/apps/backend/chatballs/conversations/selectors.py index a2ed348..5ee447e 100644 --- a/apps/backend/chatballs/conversations/selectors.py +++ b/apps/backend/chatballs/conversations/selectors.py @@ -1,6 +1,25 @@ -from django.db.models import F, Max, Q, QuerySet +from datetime import UTC, datetime -from chatballs.conversations.models import Conversation +from django.db.models import ( + Case, + DateTimeField, + F, + IntegerField, + Max, + Q, + QuerySet, + Value, + When, +) +from django.db.models.functions import Coalesce + +from chatballs.api.pagination import SortKey +from chatballs.conversations.models import ( + ControlMode, + Conversation, + LifecycleState, + Message, +) from chatballs.identity.policy import conversation_visibility from chatballs.tenancy.context import TenantContext @@ -18,12 +37,67 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]: .prefetch_related("labels") # Инбокс сортируется по времени последнего сообщения (а не по служебной # активности вроде claim/takeover); fallback — last_activity_at для - # диалогов без сообщений. - .annotate(_last_message_at=Max("messages__created_at")) - .order_by(F("_last_message_at").desc(nulls_last=True), "-last_activity_at") + # диалогов без сообщений. Coalesce вместо nulls_last: ключ сортировки + # обязан быть непустым, иначе курсор окна не сравнить (api.pagination). + .annotate(sort_at=Coalesce(Max("messages__created_at"), F("last_activity_at"))) + .order_by("-sort_at", "-id") ) +# Ключи сортировки инбокса. Порядок и правило сравнения курсора — одно и то же +# знание, поэтому оно живёт здесь, а не разъезжается по view. +ACTIVITY_KEYS = (SortKey("sort_at"), SortKey("id")) +WAITING_KEYS = ( + SortKey("_waiting_rank", descending=False), + SortKey("_wait_at", descending=False), + SortKey("sort_at"), + SortKey("id"), +) +# Заглушка ключа ожидания для диалогов, которые человека не ждут: ключ окна +# обязан быть непустым, а эти строки всё равно упорядочены следующим ключом. +_NOT_WAITING_AT = datetime(1970, 1, 1, tzinfo=UTC) + + +def order_conversations( + queryset: QuerySet[Conversation], sort: str +) -> tuple[QuerySet[Conversation], tuple[SortKey, ...]]: + """Порядок инбокса и ключи его курсора. + + Сортировка живёт на сервере вместе с окном: клиент видит не весь набор, и + переставлять в браузере ему нечего. `waiting` — «ждущие человека первыми, + дольше всех ждущий выше», остальные — по убыванию активности. + """ + if sort != "waiting": + return queryset.order_by("-sort_at", "-id"), ACTIVITY_KEYS + waits = Q(lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED) + ordered = queryset.annotate( + _waiting_rank=Case( + When(waits, then=Value(0)), default=Value(1), output_field=IntegerField() + ), + _wait_at=Case( + When(waits, then=F("sort_at")), + default=Value(_NOT_WAITING_AT), + output_field=DateTimeField(), + ), + ).order_by("_waiting_rank", "_wait_at", "-sort_at", "-id") + return ordered, WAITING_KEYS + + +def conversation_messages(conversation: Conversation) -> QuerySet[Message]: + """Лента сообщений диалога. Окно и направление задаёт вызывающий.""" + return Message.objects.filter(conversation=conversation).select_related("author_user") + + +# История читается в двух направлениях: вверх по ленте (от свежих к старым — +# открытие диалога и подгрузка при прокрутке) и вперёд от последнего известного +# сообщения (дельта обновления). +MESSAGES_OLDER_KEYS = (SortKey("created_at"), SortKey("id")) +MESSAGES_NEWER_KEYS = ( + SortKey("created_at", descending=False), + SortKey("id", descending=False), +) + + def apply_conversation_visibility( queryset: QuerySet[Conversation], context: TenantContext ) -> QuerySet[Conversation]: diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index 4a216ef..9d4b3c6 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -1,5 +1,11 @@ from chatballs.integrations.features import features_payload -from chatballs.conversations.models import ConnectionIdentity, Conversation, Message, MessageAuthor +from chatballs.conversations.models import ( + ConnectionIdentity, + Conversation, + Message, + MessageAuthor, + MessageKind, +) from chatballs.identity.avatars import user_avatar_url_in from chatballs.integrations.models import IntegrationProvider @@ -114,11 +120,17 @@ def _conversation_history(conversation: Conversation) -> list[Conversation]: def conversation_payload( conversation: Conversation, *, - with_messages: bool = False, + detailed: bool = False, last_read_id: int = 0, viewer_id: int | None = None, ) -> dict[str, object]: - last = None if with_messages else _last_message(conversation) + """Карточка диалога. + + Сообщения в неё не входят ни в одном режиме: история — отдельная лента с + собственным окном (`/messages/`), иначе открытие диалога с тысячей реплик + тянуло бы их все, да ещё и на каждом обновлении карточки. + """ + last = None if detailed else _last_message(conversation) channel = conversation.channel payload = { "id": conversation.id, @@ -148,7 +160,7 @@ def conversation_payload( "company": conversation.contact.company, "city": conversation.contact.city, "email": _contact_email(conversation), - "username": _contact_username(conversation) if with_messages else "", + "username": _contact_username(conversation) if detailed else "", } if conversation.contact_id else None @@ -190,10 +202,14 @@ def conversation_payload( "lastActivityAt": conversation.last_activity_at.isoformat(), "createdAt": conversation.created_at.isoformat(), } - if with_messages: - payload["messages"] = [message_payload(m) for m in conversation.messages.select_related("author_user").order_by("created_at", "id")] + if detailed: history = _conversation_history(conversation) payload["history"] = [_history_item(c) for c in history] + # Запрос контакта мог уйти когда угодно — в загруженном окне истории его + # может не быть, поэтому факт запроса считает сервер, а не лента. + payload["contactRequested"] = conversation.messages.filter( + kind=MessageKind.CONTACT_REQUEST + ).exists() else: payload["lastMessage"] = message_payload(last) if last else None payload["pendingCount"] = _pending_count(conversation, last_read_id) diff --git a/apps/backend/chatballs/conversations/test_attachments.py b/apps/backend/chatballs/conversations/test_attachments.py index 569fbb3..a042331 100644 --- a/apps/backend/chatballs/conversations/test_attachments.py +++ b/apps/backend/chatballs/conversations/test_attachments.py @@ -151,8 +151,10 @@ class AttachmentApiTests(AttachmentTestCase): inline = self.client.get(f"/api/v1/conversations/messages/{message.id}/attachment/?inline") self.assertIn("inline", inline.headers["Content-Disposition"]) - detail = self.client.get(f"/api/v1/conversations/{message.conversation_id}/").json() - payload = next(m for m in detail["conversation"]["messages"] if m["id"] == message.id) + history = self.client.get( + f"/api/v1/conversations/{message.conversation_id}/messages/" + ).json() + payload = next(m for m in history["items"] if m["id"] == message.id) self.assertEqual(payload["kind"], "file") self.assertEqual(payload["attachmentName"], "смета.pdf") self.assertEqual(payload["attachmentSize"], 4) diff --git a/apps/backend/chatballs/conversations/test_email_transport.py b/apps/backend/chatballs/conversations/test_email_transport.py index 845ed6d..f1c6c1e 100644 --- a/apps/backend/chatballs/conversations/test_email_transport.py +++ b/apps/backend/chatballs/conversations/test_email_transport.py @@ -20,7 +20,9 @@ from chatballs.conversations.models import Contact, Conversation, MessageAuthor from chatballs.conversations.clients import client_detail, clients_overview -from chatballs.conversations.serializers import conversation_payload +from chatballs.conversations.selectors import conversation_messages + +from chatballs.conversations.serializers import conversation_payload, message_payload from chatballs.conversations.transports import email as email_transport @@ -560,13 +562,15 @@ class EmailIngestThreadMetaTests(TestCase): conversation = Conversation.objects.get(channel=self.channel) - dialog = conversation_payload(conversation, with_messages=True) + dialog = conversation_payload(conversation, detailed=True) self.assertEqual(dialog["connection"]["provider"], "EMAIL") self.assertEqual(dialog["contact"]["email"], "ivan@example.com") - self.assertEqual(dialog["messages"][0]["contentHtml"], "") + first = conversation_messages(conversation).order_by("created_at", "id").first() + + self.assertEqual(message_payload(first)["contentHtml"], "") diff --git a/apps/backend/chatballs/conversations/test_history_window.py b/apps/backend/chatballs/conversations/test_history_window.py new file mode 100644 index 0000000..9d9dc29 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_history_window.py @@ -0,0 +1,208 @@ +"""История диалога грузится окнами, а не целиком. + +Карточка диалога не несёт сообщений вовсе; лента живёт своим endpoint'ом с +курсором в обе стороны: `before` — прокрутка вверх, `after` — то, что пришло +после последнего показанного сообщения. +""" + +from django.test import TestCase + +from chatballs.channels.models import Channel +from chatballs.conversations.models import ( + Contact, + ControlMode, + Conversation, + LifecycleState, + Message, + MessageAuthor, +) +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.identity.models import Organization +from chatballs.integrations.models import ( + Integration, + IntegrationKind, + IntegrationProvider, +) +from chatballs.testing import TenantAPIClient as APIClient + +MESSAGE_WINDOW = 50 + + +class ConversationHistoryWindowTests(TestCase): + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + self.channel = Channel.objects.create( + organization=self.organization, code="line", name="Линия" + ) + self.connection = Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="bot", + channel=self.channel, + ) + contact = Contact.objects.create(organization=self.organization, name="Иван") + self.conversation = Conversation.objects.create( + organization=self.organization, + channel=self.channel, + connection=self.connection, + contact=contact, + ) + self.messages = [ + Message.objects.create( + conversation=self.conversation, + author_type=MessageAuthor.CONTACT, + text=f"Реплика {index}", + ) + for index in range(120) + ] + self.client = APIClient() + self.client.login(username="owner@example.com", password="temporary-password") + + def _history(self, query: str = "") -> dict: + response = self.client.get( + f"/api/v1/conversations/{self.conversation.id}/messages/{query}" + ) + self.assertEqual(response.status_code, 200) + return response.json() + + def test_detail_carries_no_messages(self) -> None: + response = self.client.get(f"/api/v1/conversations/{self.conversation.id}/") + self.assertEqual(response.status_code, 200) + conversation = response.json()["conversation"] + self.assertNotIn("messages", conversation) + # Карточка контакта и цепочка прошлых обращений остаются на месте. + self.assertIn("history", conversation) + + def test_open_returns_last_window_in_chronological_order(self) -> None: + page = self._history() + self.assertEqual(len(page["items"]), MESSAGE_WINDOW) + self.assertTrue(page["hasMore"]) + ids = [item["id"] for item in page["items"]] + self.assertEqual(ids, sorted(ids)) + # Хвост переписки: последние 50 из 120. + self.assertEqual(ids, [m.id for m in self.messages[-MESSAGE_WINDOW:]]) + # Курсор указывает на самое раннее сообщение окна — с него прокрутка вверх. + self.assertEqual(page["cursor"], ids[0]) + + def test_scrolling_up_walks_whole_history_without_gaps(self) -> None: + collected: list[int] = [] + page = self._history() + collected = [item["id"] for item in page["items"]] + while page["hasMore"]: + page = self._history(f"?before={page['cursor']}") + older = [item["id"] for item in page["items"]] + self.assertEqual(older, sorted(older)) + collected = older + collected + # Ни пропусков, ни дублей: собрали ровно всю переписку. + self.assertEqual(collected, [m.id for m in self.messages]) + self.assertIsNone(page["cursor"]) + + def test_delta_returns_only_newer_messages(self) -> None: + last = self.messages[-1] + page = self._history(f"?after={last.id}") + self.assertEqual(page["items"], []) + self.assertFalse(page["hasMore"]) + fresh = Message.objects.create( + conversation=self.conversation, + author_type=MessageAuthor.OPERATOR, + text="Ответ оператора", + ) + page = self._history(f"?after={last.id}") + self.assertEqual([item["id"] for item in page["items"]], [fresh.id]) + + def test_limit_is_honoured_and_capped(self) -> None: + self.assertEqual(len(self._history("?limit=10")["items"]), 10) + # Потолок окна не даёт запросить историю целиком через ?limit=100000. + self.assertEqual(len(self._history("?limit=100000")["items"]), 120) + + def test_broken_cursor_falls_back_to_tail(self) -> None: + # Сообщение, на которое указывал курсор, могло быть удалено — лента не + # должна схлопываться в пустоту. + page = self._history("?before=99999999") + self.assertEqual(len(page["items"]), MESSAGE_WINDOW) + + def test_invalid_cursor_is_rejected(self) -> None: + response = self.client.get( + f"/api/v1/conversations/{self.conversation.id}/messages/?before=abc" + ) + self.assertEqual(response.status_code, 400) + + +class ConversationListWindowTests(TestCase): + """Инбокс — тоже окно: список диалогов не отдаётся целиком.""" + + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + self.channel = Channel.objects.create( + organization=self.organization, code="line", name="Линия" + ) + self.connection = Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="bot", + channel=self.channel, + ) + self.conversations = [self._conversation(f"Клиент {index}") for index in range(45)] + self.client = APIClient() + self.client.login(username="owner@example.com", password="temporary-password") + + def _conversation(self, name: str) -> Conversation: + contact = Contact.objects.create(organization=self.organization, name=name) + conversation = Conversation.objects.create( + organization=self.organization, + channel=self.channel, + connection=self.connection, + contact=contact, + ) + Message.objects.create( + conversation=conversation, author_type=MessageAuthor.CONTACT, text=name + ) + return conversation + + def _page(self, query: str = "") -> dict: + response = self.client.get(f"/api/v1/conversations/{query}") + self.assertEqual(response.status_code, 200) + return response.json() + + def test_first_window_is_bounded(self) -> None: + page = self._page() + self.assertEqual(len(page["items"]), 30) + self.assertTrue(page["hasMore"]) + + def test_cursor_walks_list_without_gaps(self) -> None: + seen: list[int] = [] + page = self._page() + seen += [item["id"] for item in page["items"]] + while page["hasMore"]: + page = self._page(f"?cursor={page['cursor']}") + seen += [item["id"] for item in page["items"]] + self.assertEqual(len(seen), len(set(seen))) + self.assertEqual(set(seen), {c.id for c in self.conversations}) + + def test_waiting_sort_puts_longest_waiting_first(self) -> None: + for conversation in self.conversations[:3]: + conversation.lifecycle = LifecycleState.OPEN + conversation.control_mode = ControlMode.PAUSED + conversation.save(update_fields=["lifecycle", "control_mode"]) + page = self._page("?sort=waiting") + head = [item["id"] for item in page["items"][:3]] + # Ждут дольше всех — созданы раньше остальных, значит идут первыми. + self.assertEqual(head, [c.id for c in self.conversations[:3]]) + + def test_waiting_sort_cursor_walks_list_without_gaps(self) -> None: + for conversation in self.conversations[:5]: + conversation.lifecycle = LifecycleState.OPEN + conversation.control_mode = ControlMode.PAUSED + conversation.save(update_fields=["lifecycle", "control_mode"]) + seen: list[int] = [] + page = self._page("?sort=waiting") + seen += [item["id"] for item in page["items"]] + while page["hasMore"]: + page = self._page(f"?sort=waiting&cursor={page['cursor']}") + seen += [item["id"] for item in page["items"]] + self.assertEqual(len(seen), len(set(seen))) + self.assertEqual(set(seen), {c.id for c in self.conversations}) diff --git a/apps/backend/chatballs/conversations/views.py b/apps/backend/chatballs/conversations/views.py index 52a6814..fb455c2 100644 --- a/apps/backend/chatballs/conversations/views.py +++ b/apps/backend/chatballs/conversations/views.py @@ -9,8 +9,25 @@ from chatballs.conversations.models import ( LifecycleState, Message, ) -from chatballs.conversations.selectors import visible_conversations_for +from chatballs.api.pagination import ( + cursor_id, + window, + window_payload, + window_size, +) +from chatballs.conversations.selectors import ( + MESSAGES_NEWER_KEYS, + MESSAGES_OLDER_KEYS, + conversation_messages, + order_conversations, + visible_conversations_for, +) from chatballs.conversations.serializers import conversation_payload, message_payload + +# Окно инбокса и окно истории: размеры продуктовые, клиент может запросить +# меньше, больше — только до потолка api.pagination. +LIST_WINDOW_SIZE = 30 +MESSAGE_WINDOW_SIZE = 50 from chatballs.conversations.services import ( ClaimError, claim_conversation, @@ -81,23 +98,33 @@ class ConversationListView(ConversationViewBase): Q(contact__name__icontains=query) | Q(Exists(message_match)) ) - items = list(items) + # Инбокс — живая лента: окно по курсору, а не номера страниц. Сортировка + # серверная, иначе окно и порядок разъезжаются. + ordered, keys = order_conversations(items, params.get("sort", "activity")) + page = window( + ordered, + keys=keys, + limit=window_size(params, default=LIST_WINDOW_SIZE), + after=cursor_id(params), + ) # Отметки прочтения просматривающего: бейдж считается персонально. read_map = dict( - ConversationRead.objects.filter(user=request.user, conversation__in=items) - .values_list("conversation_id", "last_read_message_id") + ConversationRead.objects.filter( + user=request.user, conversation__in=page.items + ).values_list("conversation_id", "last_read_message_id") ) return Response( - { - "items": [ - conversation_payload( - c, - last_read_id=read_map.get(c.id, 0), - viewer_id=request.user.id, - ) - for c in items - ] - } + window_payload( + page, + lambda c: conversation_payload( + c, + last_read_id=read_map.get(c.id, 0), + viewer_id=request.user.id, + ), + # Счётчик над списком показывает весь охват с учётом фильтров, + # а не число уже загруженных строк. + total=ordered.order_by().count(), + ) ) @@ -121,7 +148,7 @@ class ConversationDetailView(ConversationViewBase): { "conversation": conversation_payload( conversation, - with_messages=True, + detailed=True, viewer_id=request.user.id, ) } @@ -147,7 +174,7 @@ class ConversationClaimView(ConversationViewBase): { "conversation": conversation_payload( conversation, - with_messages=True, + detailed=True, viewer_id=request.user.id, ) } @@ -173,7 +200,7 @@ class ConversationReleaseView(ConversationViewBase): { "conversation": conversation_payload( conversation, - with_messages=True, + detailed=True, viewer_id=request.user.id, ) } @@ -199,7 +226,7 @@ class ConversationReturnQueueView(ConversationViewBase): { "conversation": conversation_payload( conversation, - with_messages=True, + detailed=True, viewer_id=request.user.id, ) } @@ -230,8 +257,46 @@ def claim_for_reply(view: ConversationViewBase, request: Request, conversation: class ConversationMessageView(ConversationViewBase): + # Читает историю тот, кто видит диалог; пишет — тот, кто его ведёт. + required_capabilities = {"GET": "conversations.view", "POST": "conversations.operate"} required_capability = "conversations.operate" + def get(self, request: Request, conversation_id: int) -> Response: + """Окно истории. + + `after` — что появилось после последнего показанного сообщения (этим + живёт обновление открытого диалога); `before` — что было до самого + раннего показанного (этим живёт прокрутка вверх). Без курсора — хвост + переписки, то есть последние сообщения. + """ + try: + conversation = self._conversation(request, conversation_id) + except Conversation.DoesNotExist: + return Response({"detail": "Диалог не найден"}, status=404) + params = request.query_params + limit = window_size(params, default=MESSAGE_WINDOW_SIZE) + messages = conversation_messages(conversation) + after = cursor_id(params, "after") + if after is not None: + return Response( + window_payload( + window(messages, keys=MESSAGES_NEWER_KEYS, limit=limit, after=after), + message_payload, + ) + ) + page = window( + messages, + keys=MESSAGES_OLDER_KEYS, + limit=limit, + after=cursor_id(params, "before"), + ) + # Курсор считается по окну «от свежих к старым» — это самое раннее + # сообщение окна, с него продолжится прокрутка вверх. Наружу список + # уходит в хронологическом порядке, как его рисует лента. + payload = window_payload(page, message_payload) + payload["items"].reverse() + return Response(payload) + def post(self, request: Request, conversation_id: int) -> Response: try: conversation = self._conversation(request, conversation_id, self.required_capability) @@ -299,7 +364,7 @@ class ConversationCloseView(ConversationViewBase): { "conversation": conversation_payload( conversation, - with_messages=True, + detailed=True, viewer_id=request.user.id, ) } @@ -325,7 +390,7 @@ class ConversationSpamView(ConversationViewBase): { "conversation": conversation_payload( conversation, - with_messages=True, + detailed=True, viewer_id=request.user.id, ) } diff --git a/apps/internal-ui/src/features/conversations/ConversationThread.tsx b/apps/internal-ui/src/features/conversations/ConversationThread.tsx index ddab286..c236493 100644 --- a/apps/internal-ui/src/features/conversations/ConversationThread.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationThread.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useRef, type ReactNode } from "react"; +import { Fragment, useRef, type ReactNode } from "react"; import { Icon } from "../../shared/icons"; import { IconButton } from "../../shared/ui-controls"; @@ -10,22 +10,26 @@ import { VoiceMessage } from "./VoiceMessage"; import { statusFor } from "./data"; import { providerMeta } from "../../shared/providers"; import type { ApiConversation, ApiMessage } from "./model"; +import type { ConversationHistory } from "./useConversationHistory"; +import { useHistoryScroll } from "./useHistoryScroll"; import type { ConversationListItem, ControlMode, StatusInfo } from "./types"; function fmtTime(value: string): string { return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); } -export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onRelease, onClose, onSpam, onReturnQueue, onArchive, onToggleContext, onMobileBack, onExpandList, viewerId = null }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onRelease: () => void; onClose: () => void; onSpam: () => Promise; onReturnQueue: () => void; onArchive: () => Promise; onToggleContext?: () => void; onMobileBack?: () => void; onExpandList?: () => void; viewerId?: number | null }) { +export function ConversationThread({ controlMode, dialog, detail, history, isOwner = false, onClaim, onRelease, onClose, onSpam, onReturnQueue, onArchive, onToggleContext, onMobileBack, onExpandList, viewerId = null }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; history: ConversationHistory; isOwner?: boolean; onClaim: () => void; onRelease: () => void; onClose: () => void; onSpam: () => Promise; onReturnQueue: () => void; onArchive: () => Promise; onToggleContext?: () => void; onMobileBack?: () => void; onExpandList?: () => void; viewerId?: number | null }) { const timelineRef = useRef(null); - const messages = detail?.messages ?? []; - const lastMessageId = messages.length ? messages[messages.length - 1].id : 0; - - // Скролл к свежим сообщениям при открытии диалога и при новых сообщениях. - useEffect(() => { - const node = timelineRef.current; - if (node) node.scrollTop = node.scrollHeight; - }, [detail?.id, lastMessageId]); + const messages = history.messages; + // Лента держит низ при новых репликах и догружает предыдущие при подходе к + // верху, сохраняя место чтения. + const { onScroll } = useHistoryScroll(timelineRef, { + conversationId: dialog?.id ?? null, + messages, + hasOlder: history.hasOlder, + loadingOlder: history.loadingOlder, + loadOlder: history.loadOlder, + }); if (!dialog) { return
Выберите диалог
; @@ -69,9 +73,9 @@ export function ConversationThread({ controlMode, dialog, detail, isOwner = fals -
+
- {messages.length === 0 &&
Пока нет сообщений
} + {history.loaded && messages.length === 0 &&
Пока нет сообщений
} {messages.map((message, index) => ( {(index === 0 || !sameDay(messages[index - 1].createdAt, message.createdAt)) && ( diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index 04dba8b..27eb0a7 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -3,13 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { CallOverlay } from "./CallOverlay"; import { Composer } from "./Composer"; import { ConversationThread } from "./ConversationThread"; -import { DialogList, type ListSort } from "./DialogList"; +import { DialogList } from "./DialogList"; import { claimConversation, closeConversation, controlModeOf, fetchConversation, - fetchConversations, markConversationAsSpam, releaseConversation, setConversationArchived, @@ -41,13 +40,17 @@ export function scopeLabel(scope: DialogScope): string { if (scope.kind === "assignee") return scope.label; return "Все диалоги"; } -import type { ConversationListItem, ListTab } from "./types"; +import type { ConversationListItem, ListSort, ListTab } from "./types"; import { useConversationCall } from "./useConversationCall"; +import { useConversationHistory } from "./useConversationHistory"; +import { useConversationList, useDebounced } from "./useConversationList"; +import { useDialogKeyboardNav } from "./useDialogKeyboardNav"; import { useIncomingMessageSound } from "./useIncomingMessageSound"; // Общий workspace диалогов (SPEC-HUB-0010 §8.2). Видимость inbox решает // backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком, -// placeholder поиска и правой панелью через render-prop. +// placeholder поиска и правой панелью через render-prop. Список и история — +// серверные окна: ни то, ни другое целиком не запрашивается. export function ConversationWorkspace({ isOwner = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: { isOwner?: boolean; listTitle?: string; @@ -65,14 +68,13 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi showScopeSwitcher?: boolean; }) { const [listTab, setListTab] = useState("all"); + const [sort, setSort] = useState("activity"); // Кадр S2: на ≤1024px контекст-панель — выдвижная поверх ленты. const [ctxOpen, setCtxOpen] = useState(false); // Кадры M1/M2: на ≤768px список и лента — отдельные экраны. const [mobileDialogOpen, setMobileDialogOpen] = useState(false); + const [listCollapsed, setListCollapsed] = useState(false); const [search, setSearch] = useState(""); - const [conversations, setConversations] = useState([]); - const [listLoaded, setListLoaded] = useState(false); - const [listError, setListError] = useState(""); const [selectedId, setSelectedId] = useState(initialConversationId ?? null); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState(""); @@ -80,17 +82,19 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi const selectedIdRef = useRef(selectedId); selectedIdRef.current = selectedId; - const loadList = useCallback(async () => { - try { - const items = await fetchConversations(scopeFilters(scope)); - setConversations(items); - setListLoaded(true); - setListError(""); - setSelectedId((current) => current ?? items[0]?.id ?? null); - } catch { - setListError("Не удалось обновить список диалогов"); - } - }, [scope]); + // Поиск, вкладка и порядок — параметры запроса: список приходит окном, и + // фильтровать в браузере было бы нечего. + // Ввод в поиске придерживается: запрос уходит, когда человек перестал печатать. + const settledSearch = useDebounced(search.trim()); + const query = useMemo(() => ({ + ...scopeFilters(scope), + ...(listTab === "wait" ? { waiting: true } : {}), + ...(listTab === "mine" ? { assigned: "me" as const } : {}), + ...(settledSearch ? { q: settledSearch } : {}), + sort, + }), [listTab, scope, settledSearch, sort]); + const list = useConversationList(query); + const history = useConversationHistory(selectedId); const loadDetail = useCallback(async (id: number) => { try { @@ -100,22 +104,18 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi setDetailError(""); } } catch { - if (selectedIdRef.current === id) { - setDetailError("Не удалось загрузить диалог"); - } + if (selectedIdRef.current === id) setDetailError("Не удалось загрузить диалог"); } }, []); - useEffect(() => { - void loadList(); - const timer = setInterval(loadList, 4000); - return () => clearInterval(timer); - }, [loadList]); - useEffect(() => { if (initialConversationId != null) setSelectedId(initialConversationId); }, [initialConversationId]); + useEffect(() => { + setSelectedId((current) => current ?? list.conversations[0]?.id ?? null); + }, [list.conversations]); + useEffect(() => { setCtxOpen(false); }, [selectedId]); @@ -126,6 +126,8 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi setDetailError(""); setActionError(""); void loadDetail(selectedId); + // Карточка диалога (статус, ответственный, метки) обновляется отдельно от + // ленты: сообщений она больше не несёт. const timer = setInterval(() => loadDetail(selectedId), 3000); return () => clearInterval(timer); }, [selectedId, loadDetail]); @@ -134,58 +136,16 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi if (selectedId != null) void loadDetail(selectedId); }, [loadDetail, selectedId]); const callController = useConversationCall({ conversationId: selectedId, onConversationChanged }); - useIncomingMessageSound(conversations, listLoaded); + useIncomingMessageSound(list.conversations, list.loaded); - // Клавиатура (SPEC-CHATBALLS-0031 §9): ↑/↓ — по списку, Enter — открыть (мобайл). - // «/» — фокус в поиск, эту клавишу держит сам SearchInput. - const filteredRef = useRef([]); - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - const target = event.target as HTMLElement | null; - if (target?.closest("input, textarea, [contenteditable], .ant-dropdown")) return; - if (event.key === "ArrowDown" || event.key === "ArrowUp") { - const list = filteredRef.current; - if (!list.length) return; - event.preventDefault(); - const index = list.findIndex((dialog) => dialog.id === selectedIdRef.current); - const next = event.key === "ArrowDown" - ? list[Math.min(index + 1, list.length - 1)] - : list[Math.max(index - 1, 0)]; - if (next) setSelectedId(next.id); - return; - } - if (event.key === "Enter" && selectedIdRef.current != null) { - setMobileDialogOpen(true); - } - } - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, []); + const dialogs = useMemo(() => list.conversations.map(toConversationListItem), [list.conversations]); + useDialogKeyboardNav({ + dialogs, + selectedId, + setSelectedId, + onOpen: () => setMobileDialogOpen(true), + }); - // Сортировка и сворачивание списка (заголовок списка, кадр A1). - const [sort, setSort] = useState("activity"); - const [listCollapsed, setListCollapsed] = useState(false); - const dialogs = useMemo(() => conversations.map(toConversationListItem), [conversations]); - const filtered = useMemo(() => { - const query = search.trim().toLowerCase(); - const items = dialogs.filter((dialog) => { - if (query && !`${dialog.name} ${dialog.email} ${dialog.agentName} ${dialog.preview}`.toLowerCase().includes(query)) return false; - if (listTab === "wait") return dialog.mode === "wait"; - if (listTab === "mine") return dialog.isMine; - return true; - }); - if (sort === "waiting") { - // Ждущие оператора — первыми, дольше всех ждущие — выше; остальные по активности. - const activity = (dialog: ConversationListItem) => conversations.find((c) => c.id === dialog.id)?.lastActivityAt ?? ""; - return [...items].sort((a, b) => { - if ((a.mode === "wait") !== (b.mode === "wait")) return a.mode === "wait" ? -1 : 1; - return a.mode === "wait" ? activity(a).localeCompare(activity(b)) : activity(b).localeCompare(activity(a)); - }); - } - return items; - }, [conversations, dialogs, listTab, search, sort]); - - filteredRef.current = filtered; const selectedDialog = dialogs.find((dialog) => dialog.id === selectedId) ?? null; const detailLoaded = detail?.id === selectedId; const controlMode = detailLoaded ? controlModeOf(detail) : "waiting"; @@ -193,7 +153,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi function applyUpdated(updated: ApiConversation) { setDetail(updated); setActionError(""); - void loadList(); + void list.refresh(); } async function updateConversation(action: (id: number) => Promise): Promise { @@ -234,11 +194,13 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi title={listTitle} searchPlaceholder={searchPlaceholder} dialogs={dialogs} - filtered={filtered} + total={list.total} + hasMore={list.hasMore} + onLoadMore={list.loadMore} listTab={listTab} selectedId={selectedId ?? -1} search={search} - errorText={listError} + errorText={list.errorText} setSearch={setSearch} setListTab={setListTab} setSelectedId={(id) => { @@ -254,8 +216,8 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi {selectedDialog && (
{ctxOpen &&
)} diff --git a/apps/internal-ui/src/features/conversations/DialogList.tsx b/apps/internal-ui/src/features/conversations/DialogList.tsx index 60ec123..1271dd8 100644 --- a/apps/internal-ui/src/features/conversations/DialogList.tsx +++ b/apps/internal-ui/src/features/conversations/DialogList.tsx @@ -1,5 +1,5 @@ import { Dropdown } from "antd"; -import type { CSSProperties, ReactNode } from "react"; +import { useCallback, type CSSProperties, type ReactNode, type UIEvent } from "react"; import { modeDots } from "./data"; import { ContactAvatar } from "./ContactAvatar"; @@ -9,12 +9,10 @@ import { ChannelGlyph } from "../../shared/badges"; import { scopeLabel, type DialogScope } from "./ConversationWorkspace"; import { agentColorOf, groupColorOf } from "./model"; import type { ConversationCounters } from "./model"; -import type { ConversationListItem, ListTab } from "./types"; +import type { ConversationListItem, ListSort, ListTab } from "./types"; import { SearchInput } from "../../shared/ui-controls"; -export type ListSort = "activity" | "waiting"; - -export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по контакту, сообщению…", viewerId = null, scope, counters, setScope, showScopeSwitcher = true, mobileHeader, hint, dialogs, filtered, listTab, selectedId, search, errorText, sort, setSort, onCollapse, setSearch, setListTab, setSelectedId }: { +export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по контакту, сообщению…", viewerId = null, scope, counters, setScope, showScopeSwitcher = true, mobileHeader, hint, dialogs, total, hasMore, onLoadMore, listTab, selectedId, search, errorText, sort, setSort, onCollapse, setSearch, setListTab, setSelectedId }: { title?: string; searchPlaceholder?: string; sort: ListSort; @@ -28,7 +26,11 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По viewerId?: number | null; hint?: ReactNode; dialogs: ConversationListItem[]; - filtered: ConversationListItem[]; + // Размер всего охвата с учётом фильтров — считает сервер; dialogs держит лишь + // загруженное окно. + total: number; + hasMore: boolean; + onLoadMore: () => void; listTab: ListTab; selectedId: number; search: string; @@ -37,18 +39,25 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По setListTab: (tab: ListTab) => void; setSelectedId: (id: number) => void; }) { - const waitCount = dialogs.filter((dialog) => dialog.mode === "wait").length; + // Счётчик ждущих — из счётчиков охвата, а не из загруженного окна. + const waitCount = counters?.waiting ?? 0; + // Лента догружается прокруткой: следующее окно запрашивается на подходе к низу. + const onScroll = useCallback((event: UIEvent) => { + if (!hasMore) return; + const node = event.currentTarget; + if (node.scrollHeight - node.scrollTop - node.clientHeight < LOAD_TRIGGER_PX) onLoadMore(); + }, [hasMore, onLoadMore]); // Ширина списка: тянется за правый край (280–520px), запоминается в браузере. const listWidth = useResizableWidth("dialogList", { fallback: 323, min: 280, max: 520 }); return (
- {mobileHeader?.({ total: dialogs.length })} + {mobileHeader?.({ total })}
{showScopeSwitcher - ? - :

{scope.kind === "all" ? title : scopeLabel(scope)}{dialogs.length}

} + ? + :

{scope.kind === "all" ? title : scopeLabel(scope)}{total}

} setListTab("wait")}>Ждут оператора{waitCount > 0 && {waitCount}}
{hint} -
+
{errorText &&
{errorText}
} {/* Кадр S1: пустой список без призыва к действию. */} {!errorText && dialogs.length === 0 && (

Диалоги появятся, когда клиенты напишут вашему агенту

)} - {filtered.map((dialog) => )} + {dialogs.map((dialog) => )}
); } +// Ближе этого к низу списка — запрашиваем следующее окно. +const LOAD_TRIGGER_PX = 320; + function DialogTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { return ; } diff --git a/apps/internal-ui/src/features/conversations/model.ts b/apps/internal-ui/src/features/conversations/model.ts index 6b83285..7c99bda 100644 --- a/apps/internal-ui/src/features/conversations/model.ts +++ b/apps/internal-ui/src/features/conversations/model.ts @@ -1,5 +1,5 @@ import { api, apiUpload } from "../../api/client"; -import type { ChannelKey, ConversationListItem, ControlMode, DialogMode } from "./types"; +import type { ChannelKey, ConversationListItem, ControlMode, DialogMode, ListSort } from "./types"; // kind: "" — текст, "contact_request" — запрос контакта, "contact" — клиент поделился номером. export type ApiMessage = { @@ -79,10 +79,16 @@ export type ApiConversation = { createdAt: string; lastMessage: ApiMessage | null; pendingCount?: number; - messages?: ApiMessage[]; + // Сообщений в карточке нет: история — отдельная лента с окном (fetchMessages). history?: HistoryItem[]; + // Запрос контакта мог уйти вне загруженного окна истории — факт считает сервер. + contactRequested?: boolean; }; +// Живые ленты (инбокс, история диалога) приходят окном: записи, признак +// продолжения и курсор на следующее окно. +export type WindowPage = { items: T[]; hasMore: boolean; cursor: number | null }; + const AVATAR_PALETTE = ["#eb6f4b", "#3b82c4", "#9254de", "#13a8a8", "#d4860b", "#52a838", "#c4413b", "#6b5be0"]; // Цвет агента и цвет точки группы — стабильно из идентификатора. Палитра идёт // по порядку создания агентов, как в дизайн-базлайне (решение 6a): первый @@ -209,18 +215,44 @@ export type ConversationListFilters = Partial<{ q: string; }>; -export const fetchConversations = (filters: ConversationListFilters = {}) => { - const params = new URLSearchParams(); - if (filters.group) params.set("group", filters.group); - if (filters.agent) params.set("agent", String(filters.agent)); - if (filters.assigned) params.set("assigned", String(filters.assigned)); - if (filters.waiting) params.set("waiting", "1"); - if (filters.lifecycle) params.set("lifecycle", filters.lifecycle); - if (filters.archived) params.set("archived", "1"); - if (filters.q) params.set("q", filters.q); - const suffix = params.size ? `?${params.toString()}` : ""; - return api<{ items: ApiConversation[] }>(`/api/v1/conversations/${suffix}`).then((r) => r.items); +// Запрос окна инбокса: фильтры, порядок и курсор — всё серверное. +export type ConversationListQuery = ConversationListFilters & { + sort?: ListSort; + cursor?: number | null; + limit?: number; }; + +export type ConversationWindow = WindowPage & { total: number }; + +export const fetchConversations = (query: ConversationListQuery = {}) => { + const params = new URLSearchParams(); + if (query.group) params.set("group", query.group); + if (query.agent) params.set("agent", String(query.agent)); + if (query.assigned) params.set("assigned", String(query.assigned)); + if (query.waiting) params.set("waiting", "1"); + if (query.lifecycle) params.set("lifecycle", query.lifecycle); + if (query.archived) params.set("archived", "1"); + if (query.q) params.set("q", query.q); + if (query.sort) params.set("sort", query.sort); + if (query.cursor) params.set("cursor", String(query.cursor)); + if (query.limit) params.set("limit", String(query.limit)); + const suffix = params.size ? `?${params.toString()}` : ""; + return api(`/api/v1/conversations/${suffix}`); +}; + +// Окно истории: без курсора — хвост переписки; before — вверх по ленте; +// after — то, что появилось после последнего показанного сообщения. +export type MessageWindowQuery = { before?: number | null; after?: number | null; limit?: number }; + +export const fetchMessages = (conversationId: number, query: MessageWindowQuery = {}) => { + const params = new URLSearchParams(); + if (query.before) params.set("before", String(query.before)); + if (query.after) params.set("after", String(query.after)); + if (query.limit) params.set("limit", String(query.limit)); + const suffix = params.size ? `?${params.toString()}` : ""; + return api>(`/api/v1/conversations/${conversationId}/messages/${suffix}`); +}; + export const fetchConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/`).then((r) => r.conversation); export const claimConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/claim/`, { method: "POST" }).then((r) => r.conversation); export const releaseConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/release/`, { method: "POST" }).then((r) => r.conversation); diff --git a/apps/internal-ui/src/features/conversations/types.ts b/apps/internal-ui/src/features/conversations/types.ts index 4da5e57..4702f1d 100644 --- a/apps/internal-ui/src/features/conversations/types.ts +++ b/apps/internal-ui/src/features/conversations/types.ts @@ -4,6 +4,9 @@ export type DialogMode = "ai" | "closed" | "operator" | "wait"; export type ControlMode = "ai" | "assigned" | "closed" | "human" | "waiting"; export type ListTab = "all" | "mine" | "wait"; +// Порядок инбокса. Считает его сервер: список приходит окном, и сортировать в +// браузере было бы нечего. +export type ListSort = "activity" | "waiting"; export type ChannelKey = "EMAIL" | "MAX" | "TG" | "WEB"; // Элемент списка диалогов (бывш. SalesDialog). Полностью generic. diff --git a/apps/internal-ui/src/features/conversations/useConversationHistory.ts b/apps/internal-ui/src/features/conversations/useConversationHistory.ts new file mode 100644 index 0000000..a2f4854 --- /dev/null +++ b/apps/internal-ui/src/features/conversations/useConversationHistory.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { fetchMessages, type ApiMessage } from "./model"; + +// Открытие диалога показывает хвост переписки; вверх история догружается по +// прокрутке, вниз — дельтой обновления. Целиком лента не запрашивается никогда: +// в диалоге может быть сколько угодно сообщений. +const HISTORY_WINDOW = 50; +const DELTA_INTERVAL_MS = 3000; + +export type ConversationHistory = { + messages: ApiMessage[]; + loaded: boolean; + hasOlder: boolean; + loadingOlder: boolean; + errorText: string; + loadOlder: () => void; + /** Подтянуть то, что появилось после последнего показанного сообщения. */ + catchUp: () => Promise; +}; + +function mergeNewer(current: ApiMessage[], incoming: ApiMessage[]): ApiMessage[] { + if (incoming.length === 0) return current; + const known = new Set(current.map((message) => message.id)); + const fresh = incoming.filter((message) => !known.has(message.id)); + return fresh.length ? [...current, ...fresh] : current; +} + +export function useConversationHistory(conversationId: number | null): ConversationHistory { + const [messages, setMessages] = useState([]); + const [loaded, setLoaded] = useState(false); + const [hasOlder, setHasOlder] = useState(false); + const [olderCursor, setOlderCursor] = useState(null); + const [loadingOlder, setLoadingOlder] = useState(false); + const [errorText, setErrorText] = useState(""); + // Курсор дельты — последний показанный id; ref, чтобы поллинг не пересоздавался + // на каждое новое сообщение. + const lastIdRef = useRef(0); + const conversationRef = useRef(conversationId); + conversationRef.current = conversationId; + + useEffect(() => { + if (conversationId == null) { + setMessages([]); + setLoaded(false); + setHasOlder(false); + setOlderCursor(null); + lastIdRef.current = 0; + return; + } + let active = true; + setMessages([]); + setLoaded(false); + setHasOlder(false); + setOlderCursor(null); + setErrorText(""); + lastIdRef.current = 0; + fetchMessages(conversationId, { limit: HISTORY_WINDOW }) + .then((page) => { + if (!active || conversationRef.current !== conversationId) return; + setMessages(page.items); + setHasOlder(page.hasMore); + setOlderCursor(page.cursor); + setLoaded(true); + lastIdRef.current = page.items.length ? page.items[page.items.length - 1].id : 0; + }) + .catch(() => { + if (active && conversationRef.current === conversationId) setErrorText("Не удалось загрузить историю диалога"); + }); + return () => { + active = false; + }; + }, [conversationId]); + + const catchUp = useCallback(async () => { + const id = conversationRef.current; + if (id == null || lastIdRef.current === 0) return; + try { + const page = await fetchMessages(id, { after: lastIdRef.current }); + if (conversationRef.current !== id || page.items.length === 0) return; + lastIdRef.current = page.items[page.items.length - 1].id; + setMessages((current) => mergeNewer(current, page.items)); + setErrorText(""); + } catch { + setErrorText("Не удалось обновить историю диалога"); + } + }, []); + + useEffect(() => { + if (conversationId == null || !loaded) return; + const timer = setInterval(() => void catchUp(), DELTA_INTERVAL_MS); + return () => clearInterval(timer); + }, [catchUp, conversationId, loaded]); + + const loadOlder = useCallback(() => { + const id = conversationRef.current; + if (id == null || olderCursor == null || loadingOlder) return; + setLoadingOlder(true); + fetchMessages(id, { before: olderCursor, limit: HISTORY_WINDOW }) + .then((page) => { + if (conversationRef.current !== id) return; + setMessages((current) => [...page.items, ...current]); + setHasOlder(page.hasMore); + setOlderCursor(page.cursor); + setErrorText(""); + }) + .catch(() => { + if (conversationRef.current === id) setErrorText("Не удалось загрузить предыдущие сообщения"); + }) + .finally(() => { + if (conversationRef.current === id) setLoadingOlder(false); + }); + }, [loadingOlder, olderCursor]); + + return { messages, loaded, hasOlder, loadingOlder, errorText, loadOlder, catchUp }; +} diff --git a/apps/internal-ui/src/features/conversations/useConversationList.test.ts b/apps/internal-ui/src/features/conversations/useConversationList.test.ts new file mode 100644 index 0000000..096e8df --- /dev/null +++ b/apps/internal-ui/src/features/conversations/useConversationList.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { mergeHead } from "./useConversationList"; +import type { ApiConversation } from "./model"; + +function conversation(id: number, lastActivityAt = "2026-09-08T10:00:00Z"): ApiConversation { + return { id, lastActivityAt } as ApiConversation; +} + +describe("mergeHead", () => { + it("ставит свежую голову перед уже загруженным хвостом", () => { + const merged = mergeHead([conversation(9)], [conversation(3), conversation(2)]); + expect(merged.map((item) => item.id)).toEqual([9, 3, 2]); + }); + + it("не оставляет дубль, когда диалог поднялся наверх", () => { + // Диалогу 2 ответили: он приходит в голове и обязан исчезнуть из хвоста. + const merged = mergeHead([conversation(2), conversation(9)], [conversation(3), conversation(2)]); + expect(merged.map((item) => item.id)).toEqual([2, 9, 3]); + }); + + it("сохраняет хвост, которого нет в голове", () => { + const merged = mergeHead([conversation(9)], [conversation(8), conversation(7)]); + expect(merged).toHaveLength(3); + }); +}); diff --git a/apps/internal-ui/src/features/conversations/useConversationList.ts b/apps/internal-ui/src/features/conversations/useConversationList.ts new file mode 100644 index 0000000..0fb93e7 --- /dev/null +++ b/apps/internal-ui/src/features/conversations/useConversationList.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + fetchConversations, + type ApiConversation, + type ConversationListQuery, +} from "./model"; + +// Инбокс — живая лента: сверху приходит окно, вниз оно догружается прокруткой. +// Обновление перезапрашивает только голову списка (туда попадает вся свежая +// активность) и вклеивает её в уже загруженное, не сбрасывая прокрутку. +const LIST_WINDOW = 30; +const REFRESH_INTERVAL_MS = 4000; +const SEARCH_DEBOUNCE_MS = 300; + +export type ConversationListState = { + conversations: ApiConversation[]; + total: number; + loaded: boolean; + hasMore: boolean; + loadingMore: boolean; + errorText: string; + loadMore: () => void; + refresh: () => Promise; +}; + +/** Голова списка заменяет свои строки в уже загруженном хвосте: диалог, который + * поднялся наверх после нового сообщения, не должен остаться и внизу. */ +export function mergeHead( + head: ApiConversation[], + tail: ApiConversation[], +): ApiConversation[] { + const fresh = new Set(head.map((conversation) => conversation.id)); + return [...head, ...tail.filter((conversation) => !fresh.has(conversation.id))]; +} + +export function useDebounced(value: T, delay = SEARCH_DEBOUNCE_MS): T { + const [settled, setSettled] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setSettled(value), delay); + return () => clearTimeout(timer); + }, [delay, value]); + return settled; +} + +export function useConversationList(query: ConversationListQuery): ConversationListState { + const [conversations, setConversations] = useState([]); + const [total, setTotal] = useState(0); + const [loaded, setLoaded] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [cursor, setCursor] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [errorText, setErrorText] = useState(""); + // Запрос сравнивается по значению: объект фильтров пересоздаётся на каждый + // рендер, и по ссылке эффект перезапускался бы бесконечно. + const key = JSON.stringify(query); + const stableQuery = useMemo(() => JSON.parse(key) as ConversationListQuery, [key]); + const loadingMoreRef = useRef(false); + + const refresh = useCallback(async () => { + try { + const page = await fetchConversations({ ...stableQuery, limit: LIST_WINDOW }); + setConversations((current) => mergeHead(page.items, current)); + setTotal(page.total); + // Курсор и признак продолжения принадлежат прокрутке: обновление головы + // не знает, докуда пользователь уже долистал, и не должно их сбрасывать. + setLoaded(true); + setErrorText(""); + } catch { + setErrorText("Не удалось обновить список диалогов"); + } + }, [stableQuery]); + + useEffect(() => { + let active = true; + setConversations([]); + setLoaded(false); + setHasMore(false); + setCursor(null); + fetchConversations({ ...stableQuery, limit: LIST_WINDOW }) + .then((page) => { + if (!active) return; + setConversations(page.items); + setTotal(page.total); + setHasMore(page.hasMore); + setCursor(page.cursor); + setLoaded(true); + setErrorText(""); + }) + .catch(() => { + if (active) setErrorText("Не удалось загрузить список диалогов"); + }); + return () => { + active = false; + }; + }, [stableQuery]); + + useEffect(() => { + const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS); + return () => clearInterval(timer); + }, [refresh]); + + const loadMore = useCallback(() => { + if (cursor == null || loadingMoreRef.current) return; + loadingMoreRef.current = true; + setLoadingMore(true); + fetchConversations({ ...stableQuery, cursor, limit: LIST_WINDOW }) + .then((page) => { + setConversations((current) => mergeHead(current, page.items)); + setHasMore(page.hasMore); + setCursor(page.cursor); + setTotal(page.total); + setErrorText(""); + }) + .catch(() => setErrorText("Не удалось загрузить следующие диалоги")) + .finally(() => { + loadingMoreRef.current = false; + setLoadingMore(false); + }); + }, [cursor, stableQuery]); + + return { conversations, total, loaded, hasMore, loadingMore, errorText, loadMore, refresh }; +} diff --git a/apps/internal-ui/src/features/conversations/useDialogKeyboardNav.ts b/apps/internal-ui/src/features/conversations/useDialogKeyboardNav.ts new file mode 100644 index 0000000..3655197 --- /dev/null +++ b/apps/internal-ui/src/features/conversations/useDialogKeyboardNav.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; + +import type { ConversationListItem } from "./types"; + +/** Клавиатура списка диалогов (SPEC-CHATBALLS-0031 §9): ↑/↓ — по списку, + * Enter — открыть диалог на мобильном. «/» держит сам SearchInput. */ +export function useDialogKeyboardNav({ + dialogs, + selectedId, + setSelectedId, + onOpen, +}: { + dialogs: ConversationListItem[]; + selectedId: number | null; + setSelectedId: (id: number) => void; + onOpen: () => void; +}): void { + const dialogsRef = useRef(dialogs); + dialogsRef.current = dialogs; + const selectedRef = useRef(selectedId); + selectedRef.current = selectedId; + const setSelectedRef = useRef(setSelectedId); + setSelectedRef.current = setSelectedId; + const onOpenRef = useRef(onOpen); + onOpenRef.current = onOpen; + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLElement | null; + if (target?.closest("input, textarea, [contenteditable], .ant-dropdown")) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + const list = dialogsRef.current; + if (!list.length) return; + event.preventDefault(); + const index = list.findIndex((dialog) => dialog.id === selectedRef.current); + const next = event.key === "ArrowDown" + ? list[Math.min(index + 1, list.length - 1)] + : list[Math.max(index - 1, 0)]; + if (next) setSelectedRef.current(next.id); + return; + } + if (event.key === "Enter" && selectedRef.current != null) onOpenRef.current(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); +} diff --git a/apps/internal-ui/src/features/conversations/useHistoryScroll.ts b/apps/internal-ui/src/features/conversations/useHistoryScroll.ts new file mode 100644 index 0000000..f845ea7 --- /dev/null +++ b/apps/internal-ui/src/features/conversations/useHistoryScroll.ts @@ -0,0 +1,76 @@ +import { useCallback, useLayoutEffect, useRef, type RefObject } from "react"; + +import type { ApiMessage } from "./model"; + +// Ближе этого к верхнему краю — подгружаем предыдущие сообщения. +const LOAD_TRIGGER_PX = 240; +// Дальше этого от низа считается, что человек читает историю: новое сообщение +// не должно дёргать ленту к последней реплике. +const STICK_TO_BOTTOM_PX = 200; + +export type HistoryScroll = { + onScroll: () => void; +}; + +/** Прокрутка ленты истории: держит низ при новых сообщениях, подгружает старые + * при подходе к верху и сохраняет место чтения, когда они вклеиваются сверху. */ +export function useHistoryScroll( + ref: RefObject, + { + conversationId, + messages, + hasOlder, + loadingOlder, + loadOlder, + }: { + conversationId: number | null; + messages: ApiMessage[]; + hasOlder: boolean; + loadingOlder: boolean; + loadOlder: () => void; + }, +): HistoryScroll { + const firstId = messages.length ? messages[0].id : 0; + const lastId = messages.length ? messages[messages.length - 1].id : 0; + // Высота ленты в момент запроса предыдущих сообщений: по разнице с новой + // высотой возвращаем взгляд на ту же реплику. + const anchorHeightRef = useRef(null); + const previousFirstIdRef = useRef(firstId); + + useLayoutEffect(() => { + const node = ref.current; + if (!node || previousFirstIdRef.current === firstId) return; + const anchorHeight = anchorHeightRef.current; + previousFirstIdRef.current = firstId; + if (anchorHeight == null) return; + node.scrollTop += node.scrollHeight - anchorHeight; + anchorHeightRef.current = null; + }, [firstId, ref]); + + useLayoutEffect(() => { + const node = ref.current; + if (!node || lastId === 0) return; + const distanceToBottom = node.scrollHeight - node.scrollTop - node.clientHeight; + // Первый показ диалога и собственные ответы приводят ленту к последней + // реплике; при чтении истории — оставляем как есть. + if (distanceToBottom <= STICK_TO_BOTTOM_PX || anchorHeightRef.current !== null) return; + node.scrollTop = node.scrollHeight; + }, [lastId, ref]); + + useLayoutEffect(() => { + const node = ref.current; + if (node) node.scrollTop = node.scrollHeight; + anchorHeightRef.current = null; + previousFirstIdRef.current = 0; + }, [conversationId, ref]); + + const onScroll = useCallback(() => { + const node = ref.current; + if (!node || !hasOlder || loadingOlder || anchorHeightRef.current !== null) return; + if (node.scrollTop > LOAD_TRIGGER_PX) return; + anchorHeightRef.current = node.scrollHeight; + loadOlder(); + }, [hasOlder, loadOlder, loadingOlder, ref]); + + return { onScroll }; +} diff --git a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx index 6d492c3..debad1d 100644 --- a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx +++ b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx @@ -52,8 +52,9 @@ export function ClientContext({ const email = contact?.email ?? ""; const phone = contact?.phone ?? ""; const username = contact?.username ?? ""; - // Запрос уже отправлен, если в диалоге есть сообщение kind=contact_request (detail поллится каждые 3 с). - const alreadyRequested = justRequested || (detail?.messages ?? []).some((m) => m.kind === "contact_request"); + // Факт запроса контакта считает сервер: сообщение могло уйти вне окна истории, + // загруженного лентой. + const alreadyRequested = justRequested || Boolean(detail?.contactRequested); const canRequest = Boolean(detail && contact && detail.connection && !phone && detail.lifecycle === "OPEN"); const isGuest = dialog.channel === "WEB" && !username && !email;