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:
2026-06-04 00:14:34 +02:00
parent bb09f0ccd3
commit 3859659b13
8 changed files with 193 additions and 78 deletions

View File

@@ -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"],
}))

View 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>
);
}

View File

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

View File

@@ -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>
)}

View File

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

View File

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

View File

@@ -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 }

View File

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