From c4253f6a78ad8ef158e57376a08438fcdc12ca62 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 11:43:29 +0300 Subject: [PATCH] Add readers avatars and emoji picker --- backend/communications/serialization.py | 21 +++++--- .../communications/test_communications_api.py | 11 ++++- frontend/package-lock.json | 22 +++++++++ frontend/package.json | 1 + frontend/src/app/messages/[chatId]/page.tsx | 4 +- .../messages/MessageReadersModal.tsx | 39 ++++++++++++++- .../messages/ReactionPickerModal.tsx | 49 ++++++++++--------- frontend/src/types/api.ts | 1 + 8 files changed, 114 insertions(+), 34 deletions(-) diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py index 1464b7f9..94db40dc 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -73,6 +73,19 @@ def _is_message_read(m) -> bool: ).exists() +def _get_user_avatar_url(user) -> str: + if not user: + return "" + + try: + if getattr(user, "avatar", None) and user.avatar: + return user.avatar.url + except Exception: + return "" + + return "" + + def _get_message_read_by(m) -> list[dict]: """Список участников, которые дочитали сообщение.""" chat = getattr(m, "chat", None) @@ -105,6 +118,7 @@ def _get_message_read_by(m) -> list[dict]: { "id": user.id, "name": user.get_full_name() or user.username, + "avatar": _get_user_avatar_url(user), } ) @@ -127,12 +141,7 @@ def serialize_message(m) -> dict: """ author = m.author author_name = author.get_full_name() or author.username - avatar = "" - try: - if getattr(author, "avatar", None) and author.avatar: - avatar = author.avatar.url - except Exception: - avatar = "" + avatar = _get_user_avatar_url(author) # Базовые поля data = { diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index ae83337c..508b1f47 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -203,6 +203,9 @@ class TestChatViewSet: def test_chat_messages_include_read_by_users(self, auth_client, private_chat, user1, user2): """Payload сообщения содержит список пользователей, которые его прочитали.""" + user2.avatar = "avatars/read-by-user2.jpg" + user2.save(update_fields=["avatar"]) + message = Message.objects.create( chat=private_chat, author=user1, @@ -223,7 +226,13 @@ class TestChatViewSet: payload = response.data["messages"][0] assert payload["is_read"] is True assert payload["read_count"] == 1 - assert payload["read_by"] == [{"id": user2.id, "name": user2.get_full_name()}] + assert payload["read_by"] == [ + { + "id": user2.id, + "name": user2.get_full_name(), + "avatar": user2.avatar.url, + } + ] def test_mark_read(self, auth_client, private_chat, user1): """Пометка чата как прочитанного""" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 19ff695e..3004a395 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@types/react-avatar-editor": "^13.0.4", "@types/xlsx": "^0.0.35", "date-fns": "^4.1.0", + "emoji-picker-react": "^4.18.0", "fuse.js": "^7.1.0", "lucide-react": "^0.574.0", "mammoth": "^1.11.0", @@ -4028,6 +4029,21 @@ "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true }, + "node_modules/emoji-picker-react": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz", + "integrity": "sha512-vLTrLfApXAIciguGE57pXPWs9lPLBspbEpPMiUq03TIli2dHZBiB+aZ0R9/Wat0xmTfcd4AuEzQgSYxEZ8C88Q==", + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -4769,6 +4785,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b3d7f064..f1c0cbbd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@types/react-avatar-editor": "^13.0.4", "@types/xlsx": "^0.0.35", "date-fns": "^4.1.0", + "emoji-picker-react": "^4.18.0", "fuse.js": "^7.1.0", "lucide-react": "^0.574.0", "mammoth": "^1.11.0", diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index e1c98dab..a797b86b 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -41,7 +41,7 @@ const ALL_REACTIONS = [ "👍", "❤️", "😂", "🔥", "👏", "🎉", "😊", "😉", "😁", "🤝", "🙏", "😮", "😢", "😡", "💯", "✅", "👀", "🤔", "😍", "😎", "🤩", "🥳", "😴", "🫡", "👌", "💪", "🙌", "🧠", "💡", "🚀", - "🎯", "⭐", "✨", "🌟", "🫶", "🤗", "😅", "🤯", "🥲", "🫠", + "🎯", "⭐", "✨", "💩", "🫶", "🤗", "😅", "🤯", "🥲", "🫠", ]; function uniqueEmoji(items: string[]): string[] { @@ -1360,6 +1360,7 @@ export default function MessageDialogPage() { if (target.closest("[data-actions-menu='true']")) return; if (target.closest("[data-actions-trigger='true']")) return; if (target.closest("[data-reaction-picker='true']")) return; + if (target.closest(".reaction-picker-modal")) return; if (target.closest("[data-composer-emoji='true']")) return; setExpandedReplyActionForId(null); @@ -1628,7 +1629,6 @@ export default function MessageDialogPage() { {reactionPickerForMessageId ? ( setReactionPickerForMessageId(null)} onSelect={(emoji) => { const message = messagesById.get(reactionPickerForMessageId); diff --git a/frontend/src/components/messages/MessageReadersModal.tsx b/frontend/src/components/messages/MessageReadersModal.tsx index 160f0b41..8d4fd3a6 100644 --- a/frontend/src/components/messages/MessageReadersModal.tsx +++ b/frontend/src/components/messages/MessageReadersModal.tsx @@ -1,8 +1,10 @@ "use client"; +import Image from "next/image"; import { CheckCheck } from "lucide-react"; import { Modal } from "@/components/ui"; +import { resolveMediaUrl } from "@/lib/url"; import type { MessageReader } from "@/types/api"; type MessageReadersModalProps = { @@ -11,6 +13,22 @@ type MessageReadersModalProps = { readers: MessageReader[]; }; +function getReaderInitials(name: string): string { + const parts = name + .split(/\s+/) + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 0) { + return "П"; + } + + return parts + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() || "") + .join("") || "П"; +} + export default function MessageReadersModal({ isOpen, onClose, readers }: MessageReadersModalProps) { return ( @@ -25,9 +43,26 @@ export default function MessageReadersModal({ isOpen, onClose, readers }: Messag {readers.map((reader) => (
- {reader.name} +
+ {reader.avatar ? ( + {reader.name} + ) : ( + {getReaderInitials(reader.name)} + )} +
+ +
+

{reader.name}

+
))} diff --git a/frontend/src/components/messages/ReactionPickerModal.tsx b/frontend/src/components/messages/ReactionPickerModal.tsx index 9e246df9..e8d98c7c 100644 --- a/frontend/src/components/messages/ReactionPickerModal.tsx +++ b/frontend/src/components/messages/ReactionPickerModal.tsx @@ -1,36 +1,39 @@ "use client"; -import { X } from "lucide-react"; +import dynamic from "next/dynamic"; +import { EmojiStyle, SuggestionMode, Theme } from "emoji-picker-react"; + +import { Modal } from "@/components/ui"; +import emojiDataRu from "emoji-picker-react/dist/data/emojis-ru.js"; + +const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false }); type ReactionPickerModalProps = { - allReactions: string[]; onClose: () => void; onSelect: (emoji: string) => void; }; -export default function ReactionPickerModal({ allReactions, onClose, onSelect }: ReactionPickerModalProps) { +export default function ReactionPickerModal({ onClose, onSelect }: ReactionPickerModalProps) { return ( -
-
-
-

Выберите реакцию

- -
-
- {allReactions.map((emoji) => ( - - ))} + +
+

Быстрый способ отреагировать на сообщение без текста.

+ +
+ onSelect(emoji.emoji)} + searchPlaceholder="Поиск смайликов" + theme={Theme.LIGHT} + emojiStyle={EmojiStyle.NATIVE} + suggestedEmojisMode={SuggestionMode.FREQUENT} + lazyLoadEmojis + previewConfig={{ showPreview: false }} + width="100%" + height={420} + />
-
+ ); } \ No newline at end of file diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 068559e4..43072853 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -539,6 +539,7 @@ export interface Message { export interface MessageReader { id: number; name: string; + avatar?: string; } export interface MessageAttachment {