Add emoji picker, reactions user_id & UI tweaks
Introduce client-side emoji picker and richer reaction UX, and propagate user IDs for reactions over the WS protocol. Backend: include user_id in reaction events. Frontend: add EmojiPicker component; update useChatSocket types to include user_id; handle "reaction" events in ChatRoomPage to update cached messages (add/remove/switch reactions). Message component: UI overhaul — action menu with slide-out actions, toggle button (⋮→✕), emoji picker trigger, grouped reaction buttons that show counts and indicate "mine" state, and various layout/cleanups. Media: defer video loading by setting preload="none" in ChatMediaGallery, MediaGallery and PostComposer to improve performance.
This commit is contained in:
@@ -84,7 +84,8 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
||||
"message_id": data["message_id"],
|
||||
"emoji": data["emoji"],
|
||||
"user": user.username,
|
||||
"action": action, # 'added' | 'removed' | 'switched'
|
||||
"user_id": user.id,
|
||||
"action": action,
|
||||
})
|
||||
|
||||
elif msg_type == "typing":
|
||||
@@ -158,6 +159,7 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
||||
"message_id": event["message_id"],
|
||||
"emoji": event["emoji"],
|
||||
"user": event["user"],
|
||||
"user_id": event["user_id"],
|
||||
"action": event["action"],
|
||||
}))
|
||||
|
||||
|
||||
42
frontend/src/components/social/EmojiPicker.tsx
Normal file
42
frontend/src/components/social/EmojiPicker.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🔥", "👏", "🎉"];
|
||||
|
||||
interface Props {
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EmojiPicker({ onSelect, onClose, className }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutside);
|
||||
return () => document.removeEventListener("mousedown", handleOutside);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={[
|
||||
"flex items-center gap-1 rounded-2xl border border-brand-lines/20 bg-brand-bg/95 px-2 py-1.5 shadow-xl backdrop-blur-sm",
|
||||
className,
|
||||
].join(" ")}
|
||||
>
|
||||
{EMOJIS.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => { onSelect(e); onClose(); }}
|
||||
className="text-xl leading-none transition-transform hover:scale-125 active:scale-110"
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,6 +122,7 @@ function ChatLightboxContent({ file }: { file: MessageFile }) {
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
preload="none"
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-xl shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
@@ -187,7 +188,7 @@ function ChatMediaItem({
|
||||
src={url}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
preload="none"
|
||||
className="h-full w-full object-cover pointer-events-none"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiTrash2, FiCornerUpLeft, FiSmile } from "react-icons/fi";
|
||||
import { FiTrash2, FiCornerUpLeft, FiSmile, FiMoreVertical, FiX } from "react-icons/fi";
|
||||
import type { Message as MessageModel } from "@/api/generated/private/models/message";
|
||||
import type { Chat } from "@/api/generated/private/models/chat";
|
||||
import ChatMediaGallery from "@/components/social/chat/ChatMediaGallery";
|
||||
import EmojiPicker from "@/components/social/EmojiPicker";
|
||||
import Avatar from "@/components/ui/Avatar";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -15,55 +17,71 @@ interface Props {
|
||||
chat: Chat | null;
|
||||
onReply?: (message: MessageModel) => void;
|
||||
onReact?: (message: MessageModel, emoji: string) => void;
|
||||
/** Triggers the flash-highlight animation on this message. */
|
||||
highlighted?: boolean;
|
||||
/** Called when the user clicks the reply preview to jump to the original. */
|
||||
onScrollToMessage?: (messageId: number) => void;
|
||||
}
|
||||
|
||||
export default function Message({ message, chat, onReply, onReact, highlighted, onScrollToMessage }: Props) {
|
||||
const { t } = useTranslation("social");
|
||||
const { user } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
function handleOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutside);
|
||||
return () => document.removeEventListener("mousedown", handleOutside);
|
||||
}, [menuOpen]);
|
||||
|
||||
const sender = message.sender as { id: number; username: string; avatar: string | null } | null;
|
||||
const isOwn = user?.id != null && sender?.id === user.id;
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm("Smazat zprávu?")) return;
|
||||
await apiSocialMessagesDestroy(String(message.id));
|
||||
// WS delete event will remove from the list; refresh cache as fallback.
|
||||
}
|
||||
|
||||
const canDelete = canDeleteMessage(user, message, chat);
|
||||
|
||||
// Compute reply state once, outside JSX.
|
||||
const replyTo = message.reply_to;
|
||||
const replyDeleted = replyTo != null && replyTo.content == null;
|
||||
|
||||
function handleJump() {
|
||||
if (replyTo && !replyDeleted) onScrollToMessage?.(replyTo.id);
|
||||
}
|
||||
|
||||
const hasMedia = (message.media_files?.length ?? 0) > 0;
|
||||
const hasText = !!message.content;
|
||||
const hasText = !!message.content;
|
||||
|
||||
// Group reactions by emoji { "👍": { count, mine } }
|
||||
const reactionGroups = (message.reactions ?? []).reduce<Record<string, { count: number; mine: boolean }>>(
|
||||
(acc, r) => {
|
||||
const emoji = r.emoji;
|
||||
if (!acc[emoji]) acc[emoji] = { count: 0, mine: false };
|
||||
acc[emoji].count++;
|
||||
if ((r.user as unknown as number) === user?.id) acc[emoji].mine = true;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
/**
|
||||
* Grid columns:
|
||||
* non-own → [28px avatar] [bubble col, ≤70%] [actions auto]
|
||||
* own → [actions auto] [bubble col, ≤70%] [28px avatar]
|
||||
*
|
||||
* Rows are always: 1=reply 2=bubble 3=timestamp 4=reactions
|
||||
* Avatar sits in col 1/3, row-start-2, self-center → locked to bubble height.
|
||||
*/
|
||||
return (
|
||||
<div
|
||||
id={`msg-${message.id}`}
|
||||
className={[
|
||||
"group grid gap-x-2 gap-y-0 px-4 py-1.5",
|
||||
isOwn
|
||||
? "grid-cols-[auto_minmax(0,70%)_28px]"
|
||||
? "grid-cols-[1fr_auto_28px]"
|
||||
: "grid-cols-[28px_minmax(0,70%)_auto]",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Row 1 – reply preview + ↩ icon + sender username */}
|
||||
{/* Row 1 – reply preview */}
|
||||
{replyTo && (
|
||||
<div
|
||||
className={[
|
||||
@@ -89,115 +107,141 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
|
||||
<span className="whitespace-nowrap italic text-brand-text/40">{t("chat.room.deletedMessage")}</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="font-bold text-brand-accent/80">
|
||||
@{replyTo.sender?.username ?? "…"}
|
||||
</span>
|
||||
<span className="ml-1 italic text-brand-text/60">
|
||||
{(replyTo.content ?? "").slice(0, 80) || "…"}
|
||||
</span>
|
||||
<span className="font-bold text-brand-accent/80">@{replyTo.sender?.username ?? "…"}</span>
|
||||
<span className="ml-1 italic text-brand-text/60">{(replyTo.content ?? "").slice(0, 80) || "…"}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ↩ icon */}
|
||||
<FiCornerUpLeft size={18} className="mr-3 shrink-0 text-brand-accent/50" />
|
||||
|
||||
{/* Sender username */}
|
||||
{!isOwn && sender?.username && (
|
||||
<span className="shrink-0 text-[11px] font-medium text-brand-accent/80">
|
||||
{sender.username}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] font-medium text-brand-accent/80">{sender.username}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2 – avatar: pinned to top so it doesn't stretch to image height */}
|
||||
{/* Row 2 – avatar */}
|
||||
<div className={`row-start-2 self-start mt-1 ${isOwn ? "col-start-3" : "col-start-1"}`}>
|
||||
<Avatar name={sender?.username} src={sender?.avatar} size={28} />
|
||||
</div>
|
||||
|
||||
{/* Row 2 – bubble
|
||||
Text-only → narrow w-fit bubble
|
||||
Media-only → no bubble shell, gallery fills the column
|
||||
Text+media → bubble for text (px-3 pt-2 pb-0), gallery bleeds to edge via -mx-3 */}
|
||||
{/* Row 2 – bubble */}
|
||||
<div
|
||||
className={[
|
||||
"col-start-2 row-start-2 overflow-hidden rounded-2xl text-sm",
|
||||
"col-start-2 row-start-2 w-fit overflow-hidden rounded-2xl text-sm",
|
||||
highlighted ? "msg-flash" : "",
|
||||
!hasMedia ? "w-fit max-w-full wrap-break-word whitespace-pre-wrap" : "max-w-[min(70%,320px)]",
|
||||
!hasMedia ? "max-w-full wrap-break-word whitespace-pre-wrap" : "max-w-xs",
|
||||
hasText ? "glass" : "",
|
||||
hasText && !hasMedia ? "px-3 py-2" : "",
|
||||
hasText && hasMedia ? "px-3 pt-2 pb-0" : "",
|
||||
isOwn
|
||||
? `justify-self-end rounded-br-sm ${hasText ? "bg-brand-accent text-brand-bg" : ""}`
|
||||
? `rounded-br-sm ${hasText ? "bg-brand-accent text-brand-bg" : ""}`
|
||||
: `rounded-bl-sm ${hasText ? "bg-brand-bgLight/70 text-brand-text" : ""}`,
|
||||
].join(" ")}
|
||||
>
|
||||
{hasText && (
|
||||
<p className="wrap-break-word whitespace-pre-wrap">{message.content}</p>
|
||||
)}
|
||||
{hasText && <p className="wrap-break-word whitespace-pre-wrap">{message.content}</p>}
|
||||
{hasMedia && (
|
||||
// -mx-3 cancels the px-3 padding so gallery is flush with the bubble edges
|
||||
<div className={hasText ? "-mx-3 mt-2" : ""}>
|
||||
<ChatMediaGallery files={message.media_files} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2 – action buttons */}
|
||||
{/* Row 2 – ⋮ trigger: actions slide left, ⋮ morphs to ✕ */}
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={[
|
||||
"row-start-2 flex items-center gap-1 self-center opacity-0 transition-opacity group-hover:opacity-100",
|
||||
isOwn ? "col-start-1" : "col-start-3",
|
||||
"row-start-2 self-center relative flex items-center",
|
||||
isOwn ? "col-start-1 ml-auto" : "col-start-3",
|
||||
menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||
"transition-opacity",
|
||||
].join(" ")}
|
||||
>
|
||||
<IconButton
|
||||
icon={<FiCornerUpLeft size={14} />}
|
||||
label={t("chat.actions.reply")}
|
||||
onClick={() => onReply?.(message)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiSmile size={14} />}
|
||||
label={t("chat.actions.react")}
|
||||
onClick={() => onReact?.(message, "👍")}
|
||||
/>
|
||||
{canDelete && (
|
||||
<IconButton
|
||||
icon={<FiTrash2 size={14} />}
|
||||
label={t("chat.actions.delete")}
|
||||
onClick={handleDelete}
|
||||
{/* Emoji picker — floats above the trigger row */}
|
||||
{pickerOpen && (
|
||||
<EmojiPicker
|
||||
className="absolute bottom-full mb-2 right-0 z-20"
|
||||
onSelect={(emoji) => { onReact?.(message, emoji); }}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions slide out to the left */}
|
||||
<div
|
||||
className={[
|
||||
"flex items-center gap-0.5 overflow-hidden transition-[max-width] duration-200 ease-out",
|
||||
menuOpen ? "max-w-[8rem]" : "max-w-0",
|
||||
].join(" ")}
|
||||
>
|
||||
<IconButton
|
||||
icon={<FiCornerUpLeft size={14} />}
|
||||
label={t("chat.actions.reply")}
|
||||
onClick={() => { onReply?.(message); setMenuOpen(false); }}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiSmile size={14} />}
|
||||
label={t("chat.actions.react")}
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
/>
|
||||
{canDelete && (
|
||||
<IconButton
|
||||
icon={<FiTrash2 size={14} />}
|
||||
label={t("chat.actions.delete")}
|
||||
onClick={() => { setMenuOpen(false); void handleDelete(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle button — ⋮ rotates into ✕ */}
|
||||
<button
|
||||
onClick={() => { setMenuOpen((v) => !v); if (menuOpen) setPickerOpen(false); }}
|
||||
className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/20 hover:text-brand-text transition-colors"
|
||||
aria-label="Akce"
|
||||
>
|
||||
<FiMoreVertical
|
||||
size={16}
|
||||
className={["absolute transition-all duration-200", menuOpen ? "opacity-0 scale-0 rotate-90" : "opacity-100 scale-100 rotate-0"].join(" ")}
|
||||
/>
|
||||
<FiX
|
||||
size={16}
|
||||
className={["absolute transition-all duration-200", menuOpen ? "opacity-100 scale-100 rotate-0" : "opacity-0 scale-0 -rotate-90"].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 3 – timestamp (bubble column) */}
|
||||
{/* Row 3 – timestamp */}
|
||||
<div
|
||||
className={[
|
||||
"col-start-2 row-start-3 mt-0.5 flex items-center gap-2 text-[11px] text-brand-text/50",
|
||||
isOwn ? "justify-self-end" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<time dateTime={String(message.created_at)}>
|
||||
{formatRelative(message.created_at)}
|
||||
</time>
|
||||
<time dateTime={String(message.created_at)}>{formatRelative(message.created_at)}</time>
|
||||
{message.is_edited && <span>· {t("chat.room.edited")}</span>}
|
||||
</div>
|
||||
|
||||
{/* Row 4 – reactions (bubble column) */}
|
||||
{message.reactions?.length > 0 && (
|
||||
{/* Row 4 – reactions grouped by emoji */}
|
||||
{Object.keys(reactionGroups).length > 0 && (
|
||||
<div
|
||||
className={[
|
||||
"col-start-2 row-start-4 mt-1 flex flex-wrap gap-1",
|
||||
isOwn ? "justify-self-end" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{message.reactions.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className="rounded-full bg-brand-bgLight/60 px-2 py-0.5 text-xs"
|
||||
{Object.entries(reactionGroups).map(([emoji, { count, mine }]) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => onReact?.(message, emoji)}
|
||||
className={[
|
||||
"flex items-center gap-1 rounded-full px-2 py-0.5 text-xs transition-colors",
|
||||
mine
|
||||
? "bg-brand-accent/20 border border-brand-accent/50 text-brand-accent"
|
||||
: "bg-brand-bgLight/60 border border-transparent hover:border-brand-lines/30 text-brand-text/80",
|
||||
].join(" ")}
|
||||
>
|
||||
{r.emoji}
|
||||
</span>
|
||||
<span>{emoji}</span>
|
||||
{count > 1 && <span>{count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -133,6 +133,7 @@ function LightboxContent({ item }: { item: PostContent }) {
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
preload="none"
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-xl shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
@@ -192,7 +193,7 @@ function MediaItem({
|
||||
if (mime.startsWith("video/")) {
|
||||
return (
|
||||
<div className="relative aspect-square w-full bg-black cursor-pointer" onClick={(e) => { e.stopPropagation(); onOpen(); }}>
|
||||
<video src={url} muted playsInline preload="metadata" className="h-full w-full object-cover pointer-events-none" />
|
||||
<video src={url} muted playsInline preload="none" className="h-full w-full object-cover pointer-events-none" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/20">
|
||||
<svg viewBox="0 0 24 24" fill="white" className="h-5 w-5 translate-x-0.5"><path d="M8 5v14l11-7z"/></svg>
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function PostComposer({ parentId, hubId, onPosted }: Props) {
|
||||
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20 bg-black"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
preload="none"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/30 pointer-events-none">
|
||||
<FiPlay size={22} className="text-white drop-shadow" fill="white" />
|
||||
|
||||
@@ -8,7 +8,7 @@ export type ChatSocketEvent =
|
||||
| { type: "new_reply_chat_message"; message_id: number; message: string; reply_to_id: number; sender_id: number; sender: string; sender_avatar: string | null; media_files?: Array<{ id: number; file: string; media_type: string; uploaded_at: string }> }
|
||||
| { type: "edit_chat_message"; message_id: number; content: string; is_edited: boolean }
|
||||
| { type: "delete_chat_message"; message_id: number }
|
||||
| { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" }
|
||||
| { type: "reaction"; message_id: number; emoji: string; user: string; user_id: number; action: "added" | "removed" | "switched" }
|
||||
| { type: "typing"; user: string; is_typing: boolean }
|
||||
| { type: "stop_typing"; user: string }
|
||||
| { type: "read_status"; user: string; chat_id: number }
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function ChatRoomPage() {
|
||||
(event: ChatSocketEvent) => {
|
||||
if (event.type === "new_chat_message" || event.type === "new_reply_chat_message") {
|
||||
let replyTo: MessageModel["reply_to"] = null;
|
||||
if (event.type === "new_reply_chat_message" && event.reply_to_id != null) {
|
||||
if (event.reply_to_id != null) {
|
||||
const cached = queryClient.getQueryData<{ pages: CursorPaginated<MessageModel>[] }>(
|
||||
messagesQueryKey(chatId),
|
||||
);
|
||||
@@ -184,6 +184,31 @@ export default function ChatRoomPage() {
|
||||
);
|
||||
} else if (event.type === "stop_typing") {
|
||||
setTypingUsers((prev) => prev.filter((u) => u !== event.user));
|
||||
} else if (event.type === "reaction") {
|
||||
queryClient.setQueryData<{
|
||||
pages: CursorPaginated<MessageModel>[];
|
||||
pageParams: unknown[];
|
||||
}>(messagesQueryKey(chatId), (old) => {
|
||||
if (!old) return old as never;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((p) => ({
|
||||
...p,
|
||||
results: p.results.map((m) => {
|
||||
if (m.id !== event.message_id) return m;
|
||||
let reactions = [...(m.reactions ?? [])] as typeof m.reactions;
|
||||
if (event.action === "added") {
|
||||
reactions = [...reactions, { id: -Date.now(), user: event.user_id, emoji: event.emoji, created_at: new Date().toISOString() } as never];
|
||||
} else if (event.action === "removed") {
|
||||
reactions = reactions.filter((r) => !((r.user as unknown as number) === event.user_id && r.emoji === event.emoji));
|
||||
} else if (event.action === "switched") {
|
||||
reactions = reactions.map((r) => (r.user as unknown as number) === event.user_id ? { ...r, emoji: event.emoji } : r);
|
||||
}
|
||||
return { ...m, reactions };
|
||||
}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[appendMessage, removeMessage, chatId, user, queryClient],
|
||||
|
||||
Reference in New Issue
Block a user