Merge branch 'develop'
This commit is contained in:
commit
ecd6c892f8
@ -219,7 +219,8 @@ def _notify_comment(message):
|
||||
|
||||
Уведомляет:
|
||||
1. Автора объекта (Post/Document/Request)
|
||||
2. Других участников обсуждения (кто оставлял комментарии)
|
||||
2. Для Department: сотрудников отдела и role-only участников отдела
|
||||
3. Автора parent comment, если это reply
|
||||
|
||||
Args:
|
||||
message: Экземпляр модели Message (в чате type='comments')
|
||||
@ -298,17 +299,9 @@ def _notify_comment(message):
|
||||
if object_author and object_author.id != author.id:
|
||||
recipient_ids.add(object_author.id)
|
||||
|
||||
# 2. Уведомляем всех, кто участвовал в обсуждении
|
||||
from ..models import Message
|
||||
previous_commenters = Message.objects.filter(
|
||||
chat=chat,
|
||||
is_deleted=False
|
||||
).exclude(
|
||||
author_id=author.id
|
||||
).values_list('author_id', flat=True).distinct()
|
||||
|
||||
if previous_commenters:
|
||||
recipient_ids.update(previous_commenters)
|
||||
# 2. Reply notification for comments is opt-in by explicit reply target.
|
||||
if message.reply_to and message.reply_to.author_id != author.id:
|
||||
recipient_ids.add(message.reply_to.author_id)
|
||||
|
||||
recipient_ids.discard(author.id)
|
||||
if not recipient_ids:
|
||||
|
||||
@ -24,6 +24,7 @@ class ChatNotificationReadService:
|
||||
NotificationVerbs.MENTION,
|
||||
NotificationVerbs.REPLY,
|
||||
NotificationVerbs.ADDED_TO_CHAT,
|
||||
NotificationVerbs.COMMENTED,
|
||||
)
|
||||
|
||||
def mark_chat_notifications_as_read(
|
||||
|
||||
@ -181,7 +181,7 @@ def test_any_active_user_can_send_message_to_department_comments_chat(
|
||||
assert chat.flags.get("show_in_messages") is True
|
||||
|
||||
|
||||
def test_department_comment_notifications_keep_chat_routing_and_include_role_only(
|
||||
def test_department_comment_notifications_only_include_department_and_role_only(
|
||||
monkeypatch,
|
||||
):
|
||||
department = Department.objects.create(name="Backoffice")
|
||||
@ -229,7 +229,7 @@ def test_department_comment_notifications_keep_chat_routing_and_include_role_onl
|
||||
recipient_ids = {payload["recipient"].id for payload in sent_notifications}
|
||||
|
||||
assert role_only.id in recipient_ids
|
||||
assert previous_commenter.id in recipient_ids
|
||||
assert previous_commenter.id not in recipient_ids
|
||||
assert member.id not in recipient_ids
|
||||
assert sent_notifications
|
||||
for payload in sent_notifications:
|
||||
@ -237,6 +237,51 @@ def test_department_comment_notifications_keep_chat_routing_and_include_role_onl
|
||||
assert payload["data"]["chat_id"] == message.chat_id
|
||||
|
||||
|
||||
def test_department_comment_reply_notifies_reply_target_outside_department(
|
||||
monkeypatch,
|
||||
):
|
||||
department = Department.objects.create(name="Operations")
|
||||
member = make_user("member@example.com")
|
||||
outsider = make_user("outsider@example.com")
|
||||
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
first_comment = create_comment(
|
||||
department,
|
||||
author=outsider,
|
||||
content="Вопрос от сотрудника вне отдела",
|
||||
)
|
||||
|
||||
sent_notifications = []
|
||||
|
||||
def capture_notification(**kwargs):
|
||||
sent_notifications.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"communications.notifications.handlers._send_notification",
|
||||
capture_notification,
|
||||
)
|
||||
|
||||
reply = create_comment(
|
||||
department,
|
||||
author=member,
|
||||
content="Ответ сотруднику",
|
||||
reply_to=first_comment,
|
||||
)
|
||||
|
||||
recipient_ids = {payload["recipient"].id for payload in sent_notifications}
|
||||
|
||||
assert outsider.id in recipient_ids
|
||||
assert member.id not in recipient_ids
|
||||
for payload in sent_notifications:
|
||||
assert payload["action_url"] == f"/messages/{reply.chat_id}"
|
||||
assert payload["data"]["chat_id"] == reply.chat_id
|
||||
|
||||
|
||||
def test_department_chat_visible_in_messages_list_but_generic_comments_hidden(
|
||||
api_client: APIClient,
|
||||
):
|
||||
|
||||
@ -150,6 +150,55 @@ class TestUserConsumerChatManagement:
|
||||
assert unrelated_notification.unread is True
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_open_chat_marks_related_commented_notifications_as_read(self, ws_communicator, user, test_chat):
|
||||
"""Открытие comments-чата должно читать уведомления verb=commented этого чата."""
|
||||
related_notification = await database_sync_to_async(Notification.objects.create)(
|
||||
recipient=user,
|
||||
verb='commented',
|
||||
description='Department chat comment notification',
|
||||
action_url=f'/messages/{test_chat.id}',
|
||||
data={'chat_id': test_chat.id, 'message_id': 12, 'object_type': 'Department'},
|
||||
)
|
||||
unrelated_notification = await database_sync_to_async(Notification.objects.create)(
|
||||
recipient=user,
|
||||
verb='commented',
|
||||
description='Other comments chat notification',
|
||||
action_url='/messages/999',
|
||||
data={'chat_id': 999, 'message_id': 13, 'object_type': 'Department'},
|
||||
)
|
||||
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
await communicator.send_json_to({
|
||||
'action': 'open_chat',
|
||||
'chat_id': test_chat.id,
|
||||
'load_history': False,
|
||||
})
|
||||
|
||||
response = await communicator.receive_json_from(timeout=5)
|
||||
assert response['type'] == 'chat_opened'
|
||||
assert response['chat_id'] == test_chat.id
|
||||
|
||||
read_all_event = await communicator.receive_json_from(timeout=5)
|
||||
assert read_all_event['type'] == 'notifications_read_all'
|
||||
assert related_notification.id in read_all_event['notification_ids']
|
||||
assert unrelated_notification.id not in read_all_event['notification_ids']
|
||||
|
||||
count_event = await communicator.receive_json_from(timeout=5)
|
||||
assert count_event['type'] == 'unread_count'
|
||||
assert count_event['count'] == 1
|
||||
|
||||
related_notification = await database_sync_to_async(Notification.objects.get)(id=related_notification.id)
|
||||
unrelated_notification = await database_sync_to_async(Notification.objects.get)(id=unrelated_notification.id)
|
||||
|
||||
assert related_notification.unread is False
|
||||
assert unrelated_notification.unread is True
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_open_nonexistent_chat(self, ws_communicator, user):
|
||||
"""Попытка открыть несуществующий чат возвращает ошибку."""
|
||||
|
||||
@ -378,6 +378,24 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chat-list-unread-badge {
|
||||
min-height: 1.35rem;
|
||||
border: 2px solid var(--surface-primary);
|
||||
box-shadow: 0 4px 10px color-mix(in srgb, #ef4444 22%, transparent);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.chat-unread-divider {
|
||||
color: var(--accent-primary-strong);
|
||||
}
|
||||
|
||||
.chat-unread-divider__label {
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary) 20%, var(--border-subtle));
|
||||
background: color-mix(in srgb, var(--accent-primary) 10%, var(--surface-primary));
|
||||
color: var(--accent-primary-strong);
|
||||
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.app-action-primary {
|
||||
border: 1px solid transparent;
|
||||
background: var(--accent-primary);
|
||||
|
||||
@ -135,6 +135,19 @@ function MessageDialogPageContent() {
|
||||
/* ── derived state ── */
|
||||
const displayMessages = useMemo(() => mergeDisplayMessages(cm.messages, cm.pendingMessages), [cm.messages, cm.pendingMessages]);
|
||||
const messagesById = useMemo(() => new Map(displayMessages.map(m => [m.id, m])), [displayMessages]);
|
||||
const unreadDividerMessageId = useMemo(() => {
|
||||
if (cm.newMessagesBelowCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unreadBelowIds = cm.unreadBelowIdsRef.current;
|
||||
if (!unreadBelowIds || unreadBelowIds.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstUnreadIncoming = displayMessages.find((message) => unreadBelowIds.has(message.id));
|
||||
return firstUnreadIncoming?.id ?? null;
|
||||
}, [cm.newMessagesBelowCount, cm.unreadBelowIdsRef, displayMessages]);
|
||||
|
||||
/**
|
||||
* Badge count: take the maximum of the server-provided unread_count
|
||||
@ -1057,6 +1070,7 @@ function MessageDialogPageContent() {
|
||||
const prevDay = index > 0 ? getMessageDayKey(displayMessages[index - 1]) : null;
|
||||
const curDate = getMessageDate(message);
|
||||
const showDayDivider = Boolean(curDate && curDay !== prevDay);
|
||||
const showUnreadDivider = unreadDividerMessageId === message.id;
|
||||
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
@ -1067,6 +1081,15 @@ function MessageDialogPageContent() {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{showUnreadDivider ? (
|
||||
<div className="chat-unread-divider mb-3 mt-1 flex items-center gap-3 px-2" role="separator" aria-label="Непрочитанные сообщения">
|
||||
<span className="h-px flex-1 bg-[color:color-mix(in_srgb,var(--accent-primary)_18%,var(--border-subtle))]" />
|
||||
<span className="chat-unread-divider__label rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.08em]">
|
||||
Непрочитанные сообщения
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[color:color-mix(in_srgb,var(--accent-primary)_18%,var(--border-subtle))]" />
|
||||
</div>
|
||||
) : null}
|
||||
<ChatMessageItem
|
||||
message={message}
|
||||
currentUserId={user?.id}
|
||||
|
||||
@ -93,6 +93,24 @@ function getChatSortTimestamp(chat: Chat): number {
|
||||
return Number.isNaN(chatCreatedTs) ? 0 : chatCreatedTs;
|
||||
}
|
||||
|
||||
function isChatVisibleInMessagesList(
|
||||
chat: Chat,
|
||||
visibleDepartmentIds: ReadonlySet<number>
|
||||
): boolean {
|
||||
const chatType = chat.chat_type || chat.type;
|
||||
|
||||
if (chatType !== "comments") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isDepartmentCommentsChat(chat)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof chat.context_object_id === "number"
|
||||
&& visibleDepartmentIds.has(chat.context_object_id);
|
||||
}
|
||||
|
||||
export default function MessagesPage() {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
@ -111,6 +129,15 @@ export default function MessagesPage() {
|
||||
}),
|
||||
[currentUserEmail, currentUserFirstName, currentUserLastName, currentUserPatronymic]
|
||||
);
|
||||
const visibleDepartmentIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
(user?.departments || []).forEach((department) => {
|
||||
if (typeof department.id === "number") {
|
||||
ids.add(department.id);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}, [user?.departments]);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [chatTypeFilter, setChatTypeFilter] = useState<string>("all");
|
||||
@ -147,7 +174,11 @@ export default function MessagesPage() {
|
||||
}
|
||||
setError(null);
|
||||
const items = await apiClient.getAllChats();
|
||||
setChats(items);
|
||||
setChats(
|
||||
items.filter((chat) =>
|
||||
isChatVisibleInMessagesList(chat, visibleDepartmentIds)
|
||||
)
|
||||
);
|
||||
hasCompletedInitialLoadRef.current = true;
|
||||
} catch (e: unknown) {
|
||||
console.error("Ошибка загрузки чатов:", e);
|
||||
@ -158,7 +189,7 @@ export default function MessagesPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [visibleDepartmentIds]);
|
||||
|
||||
const refreshChat = useCallback(async (chatId: number) => {
|
||||
if (!Number.isFinite(chatId) || chatId <= 0) {
|
||||
@ -174,6 +205,10 @@ export default function MessagesPage() {
|
||||
try {
|
||||
const chat = await apiClient.getChat(chatId);
|
||||
setChats((prev) => {
|
||||
if (!isChatVisibleInMessagesList(chat, visibleDepartmentIds)) {
|
||||
return prev.filter((item) => item.id !== chatId);
|
||||
}
|
||||
|
||||
const existingIndex = prev.findIndex((item) => item.id === chatId);
|
||||
if (existingIndex === -1) {
|
||||
return [chat, ...prev];
|
||||
@ -192,7 +227,7 @@ export default function MessagesPage() {
|
||||
} finally {
|
||||
refreshChatInFlightRef.current.delete(chatId);
|
||||
}
|
||||
}, [loadChats]);
|
||||
}, [loadChats, visibleDepartmentIds]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadChats();
|
||||
@ -523,7 +558,7 @@ export default function MessagesPage() {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative h-10 w-10">
|
||||
<div className="relative h-10 w-10 overflow-visible">
|
||||
<div className="app-avatar-fallback flex h-10 w-10 items-center justify-center overflow-hidden rounded-full text-xs font-semibold">
|
||||
{chatAvatar ? (
|
||||
<Image
|
||||
@ -544,7 +579,7 @@ export default function MessagesPage() {
|
||||
</span>
|
||||
{/* Счетчик непрочитанных */}
|
||||
{(chat.unread_count ?? 0) > 0 ? (
|
||||
<span className="app-counter-danger absolute -top-1 -right-1 z-10 flex h-5 min-w-[20px] items-center justify-center border-2 border-[var(--surface-primary)] px-1.5 text-[10px] font-bold">
|
||||
<span className="chat-list-unread-badge app-counter-danger absolute right-0 top-0 z-20 flex min-w-[1.35rem] -translate-y-1/4 translate-x-1/4 items-center justify-center px-1.5 text-[10px] font-bold leading-none">
|
||||
{chat.unread_count! > 99 ? '99+' : chat.unread_count}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user