Add readers avatars and emoji picker

This commit is contained in:
lizerk 2026-04-03 11:43:29 +03:00
parent 2b1e13632a
commit c4253f6a78
8 changed files with 114 additions and 34 deletions

View File

@ -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 = {

View File

@ -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):
"""Пометка чата как прочитанного"""

View File

@ -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",

View File

@ -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",

View File

@ -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 ? (
<ReactionPickerModal
allReactions={ALL_REACTIONS}
onClose={() => setReactionPickerForMessageId(null)}
onSelect={(emoji) => {
const message = messagesById.get(reactionPickerForMessageId);

View File

@ -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 (
<Modal isOpen={isOpen} onClose={onClose} title="Прочитали сообщение" size="sm">
@ -25,9 +43,26 @@ export default function MessageReadersModal({ isOpen, onClose, readers }: Messag
{readers.map((reader) => (
<div
key={`message-reader-${reader.id}`}
className="rounded-xl border border-gray-200 bg-white px-3 py-3 text-sm font-medium leading-5 text-gray-800"
className="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-3 py-3 text-sm font-medium leading-5 text-gray-800"
>
{reader.name}
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-sky-100 text-xs font-semibold text-sky-700 ring-1 ring-sky-200">
{reader.avatar ? (
<Image
src={resolveMediaUrl(reader.avatar)}
alt={reader.name}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
<span>{getReaderInitials(reader.name)}</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900">{reader.name}</p>
</div>
</div>
))}
</div>

View File

@ -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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-2 sm:p-4" data-reaction-picker="true">
<div className="w-full max-w-[95vw] rounded-xl bg-white p-4 shadow-xl sm:max-w-md sm:rounded-2xl sm:p-6">
<div className="mb-3 flex items-center justify-between sm:mb-4">
<h3 className="text-base font-semibold text-gray-900 sm:text-lg">Выберите реакцию</h3>
<button type="button" onClick={onClose} className="rounded-full p-1 hover:bg-gray-100" aria-label="Закрыть">
<X size={18} className="text-gray-600 sm:h-5 sm:w-5" />
</button>
</div>
<div className="grid max-h-[60vh] grid-cols-6 gap-1.5 overflow-y-auto sm:max-h-[55vh] sm:grid-cols-8 sm:gap-2">
{allReactions.map((emoji) => (
<button
key={`picker-${emoji}`}
type="button"
onClick={() => onSelect(emoji)}
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-lg hover:bg-sky-50"
>
{emoji}
</button>
))}
<Modal isOpen onClose={onClose} title="Выберите реакцию" size="sm" className="reaction-picker-modal">
<div className="space-y-4" data-reaction-picker="true">
<p className="text-sm text-gray-500">Быстрый способ отреагировать на сообщение без текста.</p>
<div className="overflow-hidden rounded-xl border border-gray-100 bg-white">
<EmojiPicker
emojiData={emojiDataRu}
onEmojiClick={(emoji) => onSelect(emoji.emoji)}
searchPlaceholder="Поиск смайликов"
theme={Theme.LIGHT}
emojiStyle={EmojiStyle.NATIVE}
suggestedEmojisMode={SuggestionMode.FREQUENT}
lazyLoadEmojis
previewConfig={{ showPreview: false }}
width="100%"
height={420}
/>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -539,6 +539,7 @@ export interface Message {
export interface MessageReader {
id: number;
name: string;
avatar?: string;
}
export interface MessageAttachment {