Add readers avatars and emoji picker
This commit is contained in:
parent
2b1e13632a
commit
c4253f6a78
@ -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 = {
|
||||
|
||||
@ -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):
|
||||
"""Пометка чата как прочитанного"""
|
||||
|
||||
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -539,6 +539,7 @@ export interface Message {
|
||||
export interface MessageReader {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user