updated chat andlayout

This commit is contained in:
David Bruno Vontor
2026-06-03 17:07:34 +02:00
parent 3d1965e5e6
commit bb09f0ccd3
13 changed files with 799 additions and 107 deletions

View File

@@ -0,0 +1,34 @@
import { privateMutator } from "@/api/privateClient";
import type { Message } from "@/api/generated/private/models/message";
export interface SendMessageParams {
chatId: number;
content?: string;
replyToId?: number;
files?: File[];
}
/**
* Sends a chat message via HTTP multipart POST.
* Use this whenever files are attached; text-only messages can go through WS.
*/
export async function sendChatMessage({
chatId,
content,
replyToId,
files,
}: SendMessageParams): Promise<Message> {
const form = new FormData();
form.append("chat", String(chatId));
if (content) form.append("content", content);
if (replyToId != null) form.append("reply_to", String(replyToId));
for (const file of files ?? []) {
form.append("files", file);
}
// privateMutator deletes Content-Type for FormData so Axios sets the multipart boundary
return privateMutator<Message>({
url: "/api/social/messages/send/",
method: "POST",
data: form,
});
}

View File

@@ -0,0 +1,240 @@
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { FiX, FiChevronLeft, FiChevronRight, FiFile, FiDownload } from "react-icons/fi";
import type { MessageFile } from "@/api/generated/private/models/messageFile";
import { mediaUrl } from "@/utils/mediaUrl";
interface Props {
files: readonly MessageFile[];
}
export default function ChatMediaGallery({ files }: Props) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const visible = files.slice(0, 4);
const overflow = files.length - visible.length;
const isOpen = lightboxIndex !== null;
const prev = useCallback(
() => setLightboxIndex((i) => (i !== null ? (i - 1 + files.length) % files.length : null)),
[files.length],
);
const next = useCallback(
() => setLightboxIndex((i) => (i !== null ? (i + 1) % files.length : null)),
[files.length],
);
const close = useCallback(() => setLightboxIndex(null), []);
useEffect(() => {
if (!isOpen) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
if (e.key === "ArrowLeft") prev();
if (e.key === "ArrowRight") next();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isOpen, close, prev, next]);
if (!files?.length) return null;
const layoutClass = visible.length === 1 ? "grid-cols-1" : "grid-cols-2";
return (
<>
<div className={`mt-2 grid ${layoutClass} gap-px overflow-hidden rounded-xl bg-brand-lines/20`}>
{visible.map((file, i) => (
<ChatMediaItem
key={file.id}
file={file}
overflowCount={i === 3 && overflow > 0 ? overflow : 0}
onOpen={() => setLightboxIndex(i)}
/>
))}
</div>
{isOpen &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={close}
>
<button
type="button"
onClick={close}
className="absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors"
>
<FiX size={20} />
</button>
{files.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); prev(); }}
className="absolute left-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors"
>
<FiChevronLeft size={24} />
</button>
)}
<ChatLightboxContent file={files[lightboxIndex!]} />
{files.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); next(); }}
className="absolute right-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors"
>
<FiChevronRight size={24} />
</button>
)}
{files.length > 1 && (
<div
className="absolute bottom-5 flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{files.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setLightboxIndex(i)}
className={[
"h-2 rounded-full transition-all duration-200",
i === lightboxIndex ? "w-5 bg-white" : "w-2 bg-white/40 hover:bg-white/70",
].join(" ")}
/>
))}
</div>
)}
</div>,
document.body,
)}
</>
);
}
function ChatLightboxContent({ file }: { file: MessageFile }) {
const url = mediaUrl(file.file) ?? "";
const type = file.media_type ?? "FILE";
if (type === "VIDEO") {
return (
<video
src={url}
controls
className="max-h-[90vh] max-w-[90vw] rounded-xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
);
}
if (type === "IMAGE") {
return (
<img
src={url}
alt=""
className="max-h-[90vh] max-w-[90vw] rounded-xl object-contain shadow-2xl select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
);
}
const filename = url.split("/").pop() ?? "soubor";
return (
<a
href={url}
download
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex flex-col items-center gap-4 rounded-2xl border border-white/20 bg-white/10 px-10 py-8 text-white hover:bg-white/20 transition-colors"
>
<FiFile size={48} />
<span className="max-w-xs truncate text-sm">{filename}</span>
<FiDownload size={20} className="opacity-70" />
</a>
);
}
function ChatMediaItem({
file,
onOpen,
overflowCount = 0,
}: {
file: MessageFile;
onOpen: () => void;
overflowCount?: number;
}) {
const url = mediaUrl(file.file) ?? "";
const type = file.media_type ?? "FILE";
const overlay =
overflowCount > 0 ? (
<div
className="absolute inset-0 flex items-center justify-center bg-black/55 cursor-pointer"
onClick={(e) => { e.stopPropagation(); onOpen(); }}
>
<span className="text-2xl font-semibold text-white">+{overflowCount}</span>
</div>
) : null;
if (type === "VIDEO") {
return (
<div
className="relative aspect-square w-full max-w-[200px] 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"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<svg viewBox="0 0 24 24" fill="white" className="h-4 w-4 translate-x-0.5">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
{overlay}
</div>
);
}
if (type === "IMAGE") {
return (
<div className="relative aspect-square w-full max-w-[200px] bg-brand-bg/60">
<img
src={url}
alt=""
className="h-full w-full cursor-zoom-in object-cover transition-opacity hover:opacity-90"
loading="lazy"
onClick={(e) => { e.stopPropagation(); onOpen(); }}
/>
{overlay}
</div>
);
}
const filename = url.split("/").pop() ?? "soubor";
return (
<div className="relative p-1">
<a
href={url}
download
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2 rounded-lg border border-brand-lines/20 bg-brand-bgLight/30 px-2 py-1.5 hover:bg-brand-lines/10 transition-colors"
>
<FiFile size={18} className="shrink-0 text-brand-text/40" />
<span className="max-w-[140px] truncate text-[11px] text-brand-text/70 leading-tight">
{filename}
</span>
<FiDownload size={12} className="shrink-0 text-brand-text/30" />
</a>
{overlay}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { FiTrash2, FiCornerUpLeft, FiSmile } 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 Avatar from "@/components/ui/Avatar";
import IconButton from "@/components/ui/IconButton";
import { useAuth } from "@/hooks/useAuth";
@@ -14,9 +15,13 @@ 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 }: Props) {
export default function Message({ message, chat, onReply, onReact, highlighted, onScrollToMessage }: Props) {
const { t } = useTranslation("social");
const { user } = useAuth();
const sender = message.sender as { id: number; username: string; avatar: string | null } | null;
@@ -30,58 +35,122 @@ export default function Message({ message, chat, onReply, onReact }: Props) {
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;
/**
* 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 className={`group flex items-end gap-2 px-4 py-1.5 ${isOwn ? "flex-row-reverse" : ""}`}>
<Avatar name={sender?.username} src={sender?.avatar} size={28} />
<div className={`flex max-w-[70%] flex-col gap-1 ${isOwn ? "items-end" : "items-start"}`}>
{message.reply_to && (
<div
className={[
"rounded-xl border-l-2 px-2.5 py-1.5 text-xs",
isOwn
? "border-white/40 bg-brand-lines/20 text-brand-text/60"
: "border-brand-accent/50 bg-brand-lines/20 text-brand-text/60",
].join(" ")}
>
<span className="font-semibold text-brand-accent">
{message.reply_to.sender?.username ?? "…"}
</span>
<span className="ml-1">
{(message.reply_to.content ?? "").slice(0, 80) || "…"}
</span>
</div>
)}
<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-[28px_minmax(0,70%)_auto]",
].join(" ")}
>
{/* Row 1 reply preview + ↩ icon + sender username */}
{replyTo && (
<div
className={[
"rounded-2xl glass px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap",
isOwn
? "bg-brand-accent text-brand-bg rounded-br-sm"
: "bg-brand-bgLight/70 text-brand-text rounded-bl-sm",
"col-start-2 row-start-1 flex items-center gap-2",
"opacity-50 transition-opacity hover:opacity-90",
isOwn ? "justify-end" : "",
].join(" ")}
>
{message.content}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-brand-text/50">
<time dateTime={String(message.created_at)}>
{formatRelative(message.created_at)}
</time>
{message.is_edited && <span>· {t("chat.room.edited")}</span>}
</div>
{message.reactions?.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{message.reactions.map((r) => (
<span
key={r.id}
className="rounded-full bg-brand-bgLight/60 px-2 py-0.5 text-xs"
>
{r.emoji}
<div
role={replyDeleted ? undefined : "button"}
tabIndex={replyDeleted ? undefined : 0}
onClick={replyDeleted ? undefined : handleJump}
onKeyDown={replyDeleted ? undefined : (e) => e.key === "Enter" && handleJump()}
className={[
"w-fit select-none rounded-lg border-l-2 px-3 py-1.5 text-xs",
replyDeleted ? "cursor-default" : "cursor-pointer",
isOwn
? "border-white/30 bg-brand-lines/10 text-brand-text/50"
: "border-brand-accent/40 bg-brand-lines/10 text-brand-text/50",
].join(" ")}
>
{replyDeleted ? (
<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>
))}
)}
</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>
)}
</div>
)}
{/* Row 2 avatar: pinned to top so it doesn't stretch to image height */}
<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 */}
<div
className={[
"col-start-2 row-start-2 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)]",
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-bl-sm ${hasText ? "bg-brand-bgLight/70 text-brand-text" : ""}`,
].join(" ")}
>
{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>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{/* Row 2 action buttons */}
<div
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",
].join(" ")}
>
<IconButton
icon={<FiCornerUpLeft size={14} />}
label={t("chat.actions.reply")}
@@ -100,6 +169,38 @@ export default function Message({ message, chat, onReply, onReact }: Props) {
/>
)}
</div>
{/* Row 3 timestamp (bubble column) */}
<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>
{message.is_edited && <span>· {t("chat.room.edited")}</span>}
</div>
{/* Row 4 reactions (bubble column) */}
{message.reactions?.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"
>
{r.emoji}
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FiSend, FiX } from "react-icons/fi";
import { FiSend, FiX, FiPaperclip, FiFile } from "react-icons/fi";
import type { Message } from "@/api/generated/private/models/message";
import Button from "@/components/ui/Button";
@@ -8,7 +8,7 @@ interface Props {
disabled?: boolean;
replyTo?: Message | null;
onCancelReply?: () => void;
onSend: (text: string, replyToId?: number) => boolean;
onSend: (text: string, replyToId?: number, files?: File[]) => boolean | Promise<boolean>;
onTyping?: (isTyping: boolean) => void;
}
@@ -21,6 +21,9 @@ export default function MessageComposer({
}: Props) {
const { t } = useTranslation("social");
const [text, setText] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [sending, setSending] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const typingTimerRef = useRef<number | null>(null);
const isTypingRef = useRef(false);
@@ -31,6 +34,15 @@ export default function MessageComposer({
};
}, [onTyping]);
// Revoke object URLs on unmount / file change to avoid memory leaks
useEffect(() => {
return () => {
files.forEach((f) => {
if ((f as any)._previewUrl) URL.revokeObjectURL((f as any)._previewUrl);
});
};
}, [files]);
function notifyTyping() {
if (!isTypingRef.current) {
isTypingRef.current = true;
@@ -43,67 +55,122 @@ export default function MessageComposer({
}, 2500);
}
function handleSubmit(e: React.FormEvent) {
function addFiles(incoming: FileList | null) {
if (!incoming) return;
const tagged = Array.from(incoming).map((f) => {
(f as any)._previewUrl = f.type.startsWith("image/") ? URL.createObjectURL(f) : null;
return f;
});
setFiles((prev) => [...prev, ...tagged].slice(0, 10)); // max 10 files
}
function removeFile(idx: number) {
setFiles((prev) => {
const next = [...prev];
const removed = next.splice(idx, 1)[0];
if ((removed as any)._previewUrl) URL.revokeObjectURL((removed as any)._previewUrl);
return next;
});
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) return;
const ok = onSend(trimmed, replyTo?.id);
if (ok) {
setText("");
if (isTypingRef.current) {
isTypingRef.current = false;
onTyping?.(false);
if (!trimmed && files.length === 0) return;
setSending(true);
try {
const ok = await onSend(trimmed || "", replyTo?.id, files.length > 0 ? files : undefined);
if (ok) {
setText("");
setFiles([]);
if (isTypingRef.current) {
isTypingRef.current = false;
onTyping?.(false);
}
onCancelReply?.();
}
onCancelReply?.();
} finally {
setSending(false);
}
}
const canSend = !disabled && !sending && (text.trim().length > 0 || files.length > 0);
return (
<form
onSubmit={handleSubmit}
className="border-t border-brand-lines/15 bg-brand-bg/60 px-4 py-3"
>
<form onSubmit={handleSubmit} className="border-t border-brand-lines/15 bg-brand-bg/60 px-4 py-3">
{replyTo && (
<div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-brand-lines/15 bg-brand-bgLight/40 px-3 py-1.5 text-xs text-brand-text/80">
<span className="truncate">
{t("chat.composer.replyTo", {
snippet: (replyTo.content ?? "").slice(0, 60),
})}
{t("chat.composer.replyTo", { snippet: (replyTo.content ?? "").slice(0, 60) })}
</span>
<button
type="button"
onClick={onCancelReply}
className="rounded-full p-1 hover:bg-brand-lines/10"
aria-label={t("chat.composer.cancelReply")}
>
<button type="button" onClick={onCancelReply} className="rounded-full p-1 hover:bg-brand-lines/10" aria-label={t("chat.composer.cancelReply")}>
<FiX size={12} />
</button>
</div>
)}
{/* File previews */}
{files.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{files.map((f, i) => (
<div key={i} className="group relative shrink-0">
{(f as any)._previewUrl ? (
<img
src={(f as any)._previewUrl}
alt={f.name}
className="h-16 w-16 rounded-lg object-cover border border-brand-lines/20"
/>
) : (
<div className="flex h-16 w-16 flex-col items-center justify-center gap-1 rounded-lg border border-brand-lines/20 bg-brand-bgLight/40 px-1">
<FiFile size={20} className="text-brand-text/50" />
<span className="w-full truncate text-center text-[9px] text-brand-text/50 leading-tight">{f.name}</span>
</div>
)}
<button
type="button"
onClick={() => removeFile(i)}
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-brand-bg border border-brand-lines/20 text-brand-text/60 hover:text-brand-text opacity-0 group-hover:opacity-100 transition-opacity"
>
<FiX size={9} />
</button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,video/*,.pdf,.doc,.docx,.zip,.txt"
className="hidden"
onChange={(e) => addFiles(e.target.files)}
onClick={(e) => { (e.target as HTMLInputElement).value = ""; }}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || sending}
className="flex h-[42px] w-[42px] shrink-0 items-center justify-center rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 text-brand-text/60 hover:text-brand-text hover:border-brand-accent transition-colors disabled:opacity-40"
aria-label={t("chat.composer.attach")}
>
<FiPaperclip size={16} />
</button>
<textarea
value={text}
onChange={(e) => {
setText(e.target.value);
notifyTyping();
}}
onChange={(e) => { setText(e.target.value); notifyTyping(); }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(e); }
}}
disabled={disabled}
disabled={disabled || sending}
rows={1}
placeholder={t("chat.composer.placeholder")}
className="min-h-[42px] max-h-[160px] flex-1 resize-none rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text placeholder:text-brand-text/40 focus:outline-none focus:border-brand-accent"
className="min-h-[42px] max-h-40 flex-1 resize-none rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text placeholder:text-brand-text/40 focus:outline-none focus:border-brand-accent"
/>
<Button
type="submit"
disabled={disabled || !text.trim()}
leftIcon={<FiSend size={14} />}
>
<Button type="submit" disabled={!canSend} leftIcon={<FiSend size={14} />}>
{t("common:send", { defaultValue: "Odeslat" })}
</Button>
</div>

View File

@@ -4,8 +4,8 @@ import { getChatSocketUrl } from "@/api/social/ws";
export type ChatSocketStatus = "idle" | "connecting" | "open" | "closed" | "error";
export type ChatSocketEvent =
| { type: "new_chat_message"; message_id: number; message: string; sender_id: number; sender: string; sender_avatar: string | null }
| { type: "new_reply_chat_message"; message_id: number; message: string; reply_to_id: number; sender_id: number; sender: string; sender_avatar: string | null }
| { type: "new_chat_message"; message_id: number; message: string; sender_id: number; sender: string; sender_avatar: string | null; reply_to_id?: number; media_files?: Array<{ id: number; file: string; media_type: string; uploaded_at: string }> }
| { 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" }

View File

@@ -85,7 +85,10 @@
"edited": "upraveno",
"disconnected": "Spojení přerušeno, obnovuji...",
"loadingHistory": "Načítání starších zpráv...",
"noMessages": "Žádné zprávy. Pošlete první!"
"noMessages": "Žádné zprávy. Pošlete první!",
"deletedMessage": "(zpráva smazána)",
"chatBeginning": "Toto je začátek konverzace",
"chatCreated": "Vytvořeno {{date}}"
},
"composer": {
"placeholder": "Napište zprávu...",

View File

@@ -105,6 +105,70 @@ h1, h2, h3 {
width: 100%;
}
/* Chat message flash highlight when jumped to via reply */
@keyframes msg-flash {
0% {
background-color: transparent;
box-shadow: 0 0 0 0 transparent;
}
20% {
background-color: color-mix(in hsl, var(--c-lines), transparent 88%);
box-shadow: 0 0 14px 4px color-mix(in hsl, var(--c-lines), transparent 65%);
}
70% {
background-color: color-mix(in hsl, var(--c-lines), transparent 88%);
box-shadow: 0 0 14px 4px color-mix(in hsl, var(--c-lines), transparent 65%);
}
100% {
background-color: transparent;
box-shadow: 0 0 0 0 transparent;
}
}
.msg-flash {
animation: msg-flash 1.4s ease;
}
/* ── Global custom scrollbar ───────────────────────────────────────────────── */
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: color-mix(in hsl, var(--c-lines), transparent 55%) transparent;
}
/* WebKit (Chrome, Edge, Safari) */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
/* Track — fully transparent */
::-webkit-scrollbar-track {
background: transparent;
}
/* Hide the up/down arrow buttons */
::-webkit-scrollbar-button {
display: none;
height: 0;
width: 0;
}
/* Thumb */
::-webkit-scrollbar-thumb {
background: color-mix(in hsl, var(--c-lines), transparent 55%);
border-radius: 99px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in hsl, var(--c-lines), transparent 30%);
}
/* Corner where horizontal + vertical scrollbars meet */
::-webkit-scrollbar-corner {
background: transparent;
}
.nav-item:focus-visible {
outline: 2px solid color-mix(in hsl, var(--c-other), transparent 20%);
outline-offset: 2px;

View File

@@ -1,11 +1,34 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { FiMenu, FiChevronsLeft } from "react-icons/fi";
import ChatSidebar from "@/components/social/chat/ChatSidebar";
export default function ChatLayout() {
const [open, setOpen] = useState(true);
return (
<div className="grid h-[calc(100vh-0px)] grid-cols-[280px_1fr]">
<ChatSidebar />
<section className="flex h-full flex-col overflow-hidden">
<div
className={[
"grid h-screen transition-[grid-template-columns] duration-200",
open ? "grid-cols-[280px_1fr]" : "grid-cols-[0px_1fr]",
].join(" ")}
>
{/* Sidebar — hidden via overflow+width collapse, not unmount (keeps scroll pos) */}
<div className="overflow-hidden">
<ChatSidebar />
</div>
<section className="relative flex h-full flex-col overflow-hidden">
{/* Sidebar toggle — sits at the top-left of the chat pane */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="absolute left-3 top-3 z-20 flex h-7 w-7 items-center justify-center rounded-full bg-brand-bgLight/60 text-brand-text/60 hover:bg-brand-lines/20 hover:text-brand-text transition-colors"
aria-label={open ? "Hide sidebar" : "Show sidebar"}
>
{open ? <FiChevronsLeft size={15} /> : <FiMenu size={15} />}
</button>
<Outlet />
</section>
</div>

View File

@@ -93,20 +93,28 @@ export default function SocialLayout() {
</div>
</aside>
{/* Main column */}
<main className={`flex-1 border-x border-brand-lines/15 max-w-[640px] ${isChat ? "h-screen overflow-hidden" : "min-h-screen"}`}>
{/* Main column — expands to fill all available space in chat mode */}
<main
className={
isChat
? "flex-1 h-screen overflow-hidden"
: "flex-1 border-x border-brand-lines/15 max-w-[640px] min-h-screen"
}
>
<Outlet />
</main>
{/* Right rail (placeholder; hidden on small screens) */}
<aside className="sticky top-0 hidden h-screen w-[300px] flex-shrink-0 py-6 lg:block">
<div className="glass rounded-2xl p-4 text-sm text-brand-text/70">
<div className="font-semibold text-brand-text mb-2">
{t("nav.hubs")}
{/* Right rail hidden in chat (chat needs the space) */}
{!isChat && (
<aside className="sticky top-0 hidden h-screen w-[300px] shrink-0 py-6 lg:block">
<div className="glass rounded-2xl p-4 text-sm text-brand-text/70">
<div className="font-semibold text-brand-text mb-2">
{t("nav.hubs")}
</div>
<p className="text-xs"></p>
</div>
<p className="text-xs"></p>
</div>
</aside>
</aside>
)}
</div>
</div>
);

View File

@@ -10,6 +10,7 @@ import { useInfiniteMessages } from "@/hooks/useInfiniteMessages";
import { useChatSocket, type ChatSocketEvent } from "@/hooks/useChatSocket";
import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
import { messagesQueryKey, type CursorPaginated } from "@/api/social/feed";
import { sendChatMessage } from "@/api/social/chatSend";
import Message from "@/components/social/chat/Message";
import MessageComposer from "@/components/social/chat/MessageComposer";
import ChatSettingsModal from "@/components/social/chat/ChatSettingsModal";
@@ -38,6 +39,53 @@ export default function ChatRoomPage() {
const [settingsOpen, setSettingsOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement | null>(null);
// --- reply-jump logic ---
const [highlightedId, setHighlightedId] = useState<number | null>(null);
/** Message id we're waiting to appear in the DOM while fetching older pages. */
const [pendingScrollId, setPendingScrollId] = useState<number | null>(null);
/** IDs removed by a delete WS event — so we don't hunt for them in older pages. */
const deletedMessageIds = useRef(new Set<number>());
function flashMessage(id: number) {
setHighlightedId(id);
setTimeout(() => setHighlightedId(null), 1500);
}
function scrollToMessage(id: number) {
// Don't chase a message that was deleted during this session.
if (deletedMessageIds.current.has(id)) return;
const el = document.getElementById(`msg-${id}`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
flashMessage(id);
} else if (hasNextPage) {
// Message not rendered yet — start fetching older pages until it appears.
setPendingScrollId(id);
void fetchNextPage();
}
// else: exhausted history, message not found — silently ignore.
}
// After every page load, try to resolve a pending scroll-to.
useEffect(() => {
if (!pendingScrollId) return;
const el = document.getElementById(`msg-${pendingScrollId}`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
flashMessage(pendingScrollId);
setPendingScrollId(null);
} else if (!isFetchingNextPage) {
if (hasNextPage) {
void fetchNextPage(); // keep paging back until found
} else {
setPendingScrollId(null); // exhausted history, give up
}
}
// Re-run whenever a new page arrives or fetching state changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.length, isFetchingNextPage]);
// Top sentinel triggers loading older messages (scroll-back).
const topSentinelRef = useIntersectionLoader<HTMLDivElement>(
() => {
@@ -68,6 +116,9 @@ export default function ChatRoomPage() {
const removeMessage = useCallback(
(messageId: number) => {
deletedMessageIds.current.add(messageId);
// Also cancel any pending scroll to this message.
setPendingScrollId((prev) => (prev === messageId ? null : prev));
queryClient.setQueryData<{
pages: CursorPaginated<MessageModel>[];
pageParams: unknown[];
@@ -77,7 +128,15 @@ export default function ChatRoomPage() {
...old,
pages: old.pages.map((p) => ({
...p,
results: p.results.filter((m) => m.id !== messageId),
results: p.results
// Remove the deleted message itself.
.filter((m) => m.id !== messageId)
// Tombstone reply_to.content for any message quoting the deleted one.
.map((m) =>
m.reply_to?.id === messageId
? { ...m, reply_to: { ...m.reply_to, content: undefined } }
: m,
),
})),
};
});
@@ -108,7 +167,7 @@ export default function ChatRoomPage() {
edited_at: null,
created_at: new Date(),
updated_at: new Date(),
media_files: [],
media_files: (event.media_files ?? []) as never,
reactions: [],
});
} else if (event.type === "delete_chat_message") {
@@ -140,14 +199,27 @@ export default function ChatRoomPage() {
if (status === "open") sendMarkRead();
}, [status, sendMarkRead]);
// Auto-scroll to bottom and mark chat as read when new messages arrive.
// Auto-scroll to bottom when new messages arrive.
// Suppressed while chasing a reply-jump so both effects don't fight each other.
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
if (!pendingScrollId) {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}
if (status === "open") sendMarkRead();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.length]);
function handleSend(text: string, replyToId?: number): boolean {
async function handleSend(text: string, replyToId?: number, files?: File[]): Promise<boolean> {
// Files must go via HTTP multipart; WS handles text-only messages.
if (files?.length) {
try {
const msg = await sendChatMessage({ chatId, content: text || undefined, replyToId, files });
appendMessage(msg); // WS broadcast will deduplicate via the id-check in appendMessage
return true;
} catch {
return false;
}
}
return replyToId ? sendReply(text, replyToId) : sendMessage(text);
}
@@ -214,6 +286,35 @@ export default function ChatRoomPage() {
<EmptyState message={t("chat.room.noMessages")} />
)}
{/* Beginning-of-chat banner — shown only once all pages are confirmed loaded */}
{!hasNextPage && !isLoading && messages.length > 0 && chat && (
<div className="flex flex-col items-center gap-3 px-6 py-10 text-center">
<Avatar
name={chat.name ?? `Chat ${chatId}`}
src={chat.icon ?? undefined}
size={72}
/>
<div>
<p className="text-base font-bold text-brand-text">
{chat.name || `Chat #${chatId}`}
</p>
<p className="mt-1 text-xs text-brand-text/50">
{t("chat.room.chatBeginning")}
</p>
<p className="mt-0.5 text-xs text-brand-text/40">
{t("chat.room.chatCreated", {
date: new Date(chat.created_at).toLocaleDateString("cs-CZ", {
day: "numeric",
month: "long",
year: "numeric",
}),
})}
</p>
</div>
<div className="h-px w-24 bg-brand-lines/20" />
</div>
)}
{messages.map((m) => (
<Message
key={m.id}
@@ -221,6 +322,8 @@ export default function ChatRoomPage() {
chat={chat ?? null}
onReply={setReplyTo}
onReact={(msg, emoji) => sendReaction(msg.id, emoji)}
highlighted={m.id === highlightedId}
onScrollToMessage={scrollToMessage}
/>
))}