mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
⚡ 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:
1 parent
b601a72608
commit
0636ef5fa9
20 files changed
+1264
-163
No files matched your search
@@ -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})
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user