⚡ perf(conversations): история и инбокс грузятся окнами, а не целиком

Открытие диалога тянуло все сообщения разом: карточка несла messages без
ограничения, а workspace перезапрашивал её каждые 3 секунды — тысяча реплик
уезжала клиенту двадцать раз в минуту. Список диалогов приходил так же целиком
и обновлялся раз в 4 секунды.

Серверная часть:
- api/pagination: единый контракт — страницы (page/pageSize/total/pageCount) и
  окно по курсору (keyset) с потолками размера;
- карточка диалога больше не несёт сообщений ни в одном режиме; факт запроса
  контакта считает сервер, ленте для этого история не нужна;
- GET /conversations/<id>/messages/ — окно истории: before — вверх по ленте,
  after — дельта после последнего показанного сообщения;
- инбокс отдаётся окном по курсору с серверной сортировкой (активность или
  «ждущие первыми») и total по текущим фильтрам.

Клиент:
- useConversationHistory: хвост переписки при открытии, догрузка вверх,
  обновление дельтой вместо перезапроса всей ленты;
- useHistoryScroll: лента держит низ, подгружает старые сообщения у верхней
  границы и сохраняет место чтения при вклейке;
- useConversationList: окно инбокса, догрузка прокруткой, обновление головы
  списка без сброса прокрутки;
- поиск, вкладка и сортировка списка переехали в параметры запроса —
  фильтровать окно в браузере было бы неправильно;
- клавиатурная навигация вынесена в useDialogKeyboardNav.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-08 23:00:08 +03:00
1 parent b601a72608
commit 0636ef5fa9
20 files changed
+1264 -163

No files matched your search

+207
View File
@@ -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)
@@ -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"))
@@ -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
)
}
)
@@ -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]:
@@ -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)
@@ -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)
@@ -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"], "")
@@ -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})
+85 -20
View File
@@ -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,
)
}
@@ -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<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean>; 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<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean>; onToggleContext?: () => void; onMobileBack?: () => void; onExpandList?: () => void; viewerId?: number | null }) {
const timelineRef = useRef<HTMLDivElement>(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 <div className="sales-timeline"><div className="sales-timeline-inner"><div className="sales-wait-note">Выберите диалог</div></div></div>;
@@ -69,9 +73,9 @@ export function ConversationThread({ controlMode, dialog, detail, isOwner = fals
<ConversationActions open={detail?.lifecycle === "OPEN"} canReturnQueue={controlMode === "human"} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} />
</div>
</div>
<div className="sales-timeline" ref={timelineRef}>
<div className="sales-timeline" ref={timelineRef} onScroll={onScroll}>
<div className="sales-timeline-inner">
{messages.length === 0 && <div className="sales-wait-note">Пока нет сообщений</div>}
{history.loaded && messages.length === 0 && <div className="sales-wait-note">Пока нет сообщений</div>}
{messages.map((message, index) => (
<Fragment key={message.id}>
{(index === 0 || !sameDay(messages[index - 1].createdAt, message.createdAt)) && (
@@ -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<ListTab>("all");
const [sort, setSort] = useState<ListSort>("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<ApiConversation[]>([]);
const [listLoaded, setListLoaded] = useState(false);
const [listError, setListError] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(initialConversationId ?? null);
const [detail, setDetail] = useState<ApiConversation | null>(null);
const [detailError, setDetailError] = useState("");
@@ -80,17 +82,19 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
const selectedIdRef = useRef<number | null>(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<ConversationListItem[]>([]);
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<ListSort>("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<ApiConversation>): Promise<boolean> {
@@ -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 && (
<section className="sales-conversation enter-surface" key={selectedDialog.id}>
{ctxOpen && <button className="ctx-backdrop" type="button" aria-label="Закрыть панель" onClick={() => setCtxOpen(false)} />}
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onExpandList={listCollapsed ? () => setListCollapsed(false) : undefined} viewerId={viewerId} onClaim={onClaim} onRelease={onRelease} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
{(detailError || actionError) && <div className="sales-conversation-error">{detailError || actionError}</div>}
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} history={history} isOwner={isOwner} onExpandList={listCollapsed ? () => setListCollapsed(false) : undefined} viewerId={viewerId} onClaim={onClaim} onRelease={onRelease} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
{(detailError || actionError || history.errorText) && <div className="sales-conversation-error">{detailError || actionError || history.errorText}</div>}
<CallOverlay
open={callController.open}
dialog={selectedDialog}
@@ -279,7 +241,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
onRelease={onRelease}
onReturnQueue={onReturnQueue}
onClose={onClose}
onSent={() => selectedId != null && loadDetail(selectedId)}
onSent={() => { void history.catchUp(); void list.refresh(); }}
/>
</section>
)}
@@ -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<HTMLDivElement>) => {
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 (
<section className={`sales-dialog-list ${listWidth.dragging ? "is-resizing" : ""}`} style={{ "--dialog-list-width": `${listWidth.width}px` } as CSSProperties}>
<div className="pane-resizer" role="separator" aria-orientation="vertical" aria-label="Ширина списка диалогов" title="Потяните, двойной клик — сбросить" onPointerDown={listWidth.onPointerDown} onDoubleClick={listWidth.reset} />
{mobileHeader?.({ total: dialogs.length })}
{mobileHeader?.({ total })}
<div className="sales-dialog-list-head">
<div>
{showScopeSwitcher
? <ScopeSwitcher scope={scope} counters={counters} setScope={setScope} fallbackTitle={title} total={dialogs.length} viewerId={viewerId} />
: <h2 className="sales-dialog-list-title">{scope.kind === "all" ? title : scopeLabel(scope)}<small>{dialogs.length}</small></h2>}
? <ScopeSwitcher scope={scope} counters={counters} setScope={setScope} fallbackTitle={title} total={total} viewerId={viewerId} />
: <h2 className="sales-dialog-list-title">{scope.kind === "all" ? title : scopeLabel(scope)}<small>{total}</small></h2>}
<span className="sales-dialog-list-tools">
<Dropdown
trigger={["click"]}
@@ -74,18 +83,21 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
<DialogTab active={listTab === "wait"} onClick={() => setListTab("wait")}>Ждут оператора{waitCount > 0 && <b>{waitCount}</b>}</DialogTab>
</div>
{hint}
<div className="sales-dialog-list-body">
<div className="sales-dialog-list-body" onScroll={onScroll}>
{errorText && <div className="sales-wait-note sales-load-error">{errorText}</div>}
{/* Кадр S1: пустой список без призыва к действию. */}
{!errorText && dialogs.length === 0 && (
<div className="sales-dialog-list-empty"><span><Icon name="message" size={20} /></span><p>Диалоги появятся, когда клиенты напишут вашему агенту</p></div>
)}
{filtered.map((dialog) => <DialogListItem dialog={dialog} active={dialog.id === selectedId} setSelectedId={setSelectedId} key={dialog.id} />)}
{dialogs.map((dialog) => <DialogListItem dialog={dialog} active={dialog.id === selectedId} setSelectedId={setSelectedId} key={dialog.id} />)}
</div>
</section>
);
}
// Ближе этого к низу списка — запрашиваем следующее окно.
const LOAD_TRIGGER_PX = 320;
function DialogTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
return <button className={active ? "active" : ""} onClick={onClick}>{children}</button>;
}
@@ -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<T> = { 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<ApiConversation> & { 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<ConversationWindow>(`/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<WindowPage<ApiMessage>>(`/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);
@@ -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.
@@ -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<void>;
};
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<ApiMessage[]>([]);
const [loaded, setLoaded] = useState(false);
const [hasOlder, setHasOlder] = useState(false);
const [olderCursor, setOlderCursor] = useState<number | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [errorText, setErrorText] = useState("");
// Курсор дельты — последний показанный id; ref, чтобы поллинг не пересоздавался
// на каждое новое сообщение.
const lastIdRef = useRef(0);
const conversationRef = useRef<number | null>(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 };
}
@@ -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);
});
});
@@ -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<void>;
};
/** Голова списка заменяет свои строки в уже загруженном хвосте: диалог, который
* поднялся наверх после нового сообщения, не должен остаться и внизу. */
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<T>(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<ApiConversation[]>([]);
const [total, setTotal] = useState(0);
const [loaded, setLoaded] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState<number | null>(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 };
}
@@ -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);
}, []);
}
@@ -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<HTMLDivElement | null>,
{
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<number | null>(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 };
}
@@ -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;