updated chat andlayout
This commit is contained in:
@@ -120,9 +120,11 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
||||
"type": "new_chat_message",
|
||||
"message_id": event["message_id"],
|
||||
"message": event["message"],
|
||||
"sender_id": event["sender_id"],
|
||||
"sender_id": event.get("sender_id"),
|
||||
"sender": event["sender"],
|
||||
"sender_avatar": event["sender_avatar"],
|
||||
"sender_avatar": event.get("sender_avatar"),
|
||||
"reply_to_id": event.get("reply_to_id"),
|
||||
"media_files": event.get("media_files", []),
|
||||
}))
|
||||
|
||||
async def reply_chat_message(self, event):
|
||||
|
||||
@@ -4,6 +4,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from .models import Chat, ChatReadStatus, Message, MessageFile, MessageHistory, MessageReaction
|
||||
|
||||
|
||||
|
||||
class MessageSenderSerializer(serializers.ModelSerializer):
|
||||
avatar = serializers.SerializerMethodField()
|
||||
|
||||
@@ -51,10 +52,42 @@ class ReplyToSerializer(serializers.ModelSerializer):
|
||||
|
||||
class MessageSerializer(serializers.ModelSerializer):
|
||||
sender = MessageSenderSerializer(read_only=True)
|
||||
reply_to = ReplyToSerializer(read_only=True)
|
||||
# reply_to is a SerializerMethodField so we can bypass ActiveManager and
|
||||
# still surface a tombstone when the original message is soft-deleted.
|
||||
reply_to = serializers.SerializerMethodField()
|
||||
media_files = MessageFileSerializer(many=True, read_only=True)
|
||||
reactions = MessageReactionSerializer(many=True, read_only=True)
|
||||
|
||||
@extend_schema_field(ReplyToSerializer)
|
||||
def get_reply_to(self, obj):
|
||||
"""
|
||||
Fetch the reply-to message via all_objects so soft-deleted originals
|
||||
are still accessible. Returns content=None when the message is deleted,
|
||||
which the frontend renders as a '(zpráva smazána)' tombstone.
|
||||
"""
|
||||
reply_to_id = obj.reply_to_id
|
||||
if not reply_to_id:
|
||||
return None
|
||||
try:
|
||||
msg = Message.all_objects.select_related('sender').get(pk=reply_to_id)
|
||||
except Message.DoesNotExist:
|
||||
return None
|
||||
|
||||
sender_data = None
|
||||
if msg.sender:
|
||||
from django.conf import settings
|
||||
avatar = (settings.MEDIA_URL + msg.sender.avatar.name) if msg.sender.avatar else None
|
||||
sender_data = {'id': msg.sender.id, 'username': msg.sender.username, 'avatar': avatar}
|
||||
else:
|
||||
sender_data = {'id': 0, 'username': '…', 'avatar': None}
|
||||
|
||||
return {
|
||||
'id': msg.id,
|
||||
# content=None signals the frontend to show the deleted tombstone
|
||||
'content': None if msg.is_deleted else msg.content,
|
||||
'sender': sender_data,
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = [
|
||||
|
||||
@@ -241,12 +241,26 @@ class MessageViewSet(viewsets.ModelViewSet):
|
||||
mt = 'FILE'
|
||||
MessageFile.objects.create(message=message, file=f, media_type=mt)
|
||||
|
||||
from django.conf import settings
|
||||
avatar_url = (settings.MEDIA_URL + request.user.avatar.name) if request.user.avatar else None
|
||||
media_files_data = [
|
||||
{
|
||||
'id': f.id,
|
||||
'file': settings.MEDIA_URL + f.file.name if f.file else '',
|
||||
'media_type': f.media_type,
|
||||
'uploaded_at': f.uploaded_at.isoformat(),
|
||||
}
|
||||
for f in message.media_files.all()
|
||||
]
|
||||
_broadcast(chat.id, {
|
||||
'type': 'chat.message',
|
||||
'message_id': message.id,
|
||||
'message': message.content,
|
||||
'sender_id': request.user.id,
|
||||
'sender': request.user.username,
|
||||
'has_files': message.media_files.exists(),
|
||||
'sender_avatar': avatar_url,
|
||||
'reply_to_id': message.reply_to_id,
|
||||
'media_files': media_files_data,
|
||||
})
|
||||
|
||||
return Response(
|
||||
|
||||
34
frontend/src/api/social/chatSend.ts
Normal file
34
frontend/src/api/social/chatSend.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
240
frontend/src/components/social/chat/ChatMediaGallery.tsx
Normal file
240
frontend/src/components/social/chat/ChatMediaGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
id={`msg-${message.id}`}
|
||||
className={[
|
||||
"rounded-xl border-l-2 px-2.5 py-1.5 text-xs",
|
||||
"group grid gap-x-2 gap-y-0 px-4 py-1.5",
|
||||
isOwn
|
||||
? "border-white/40 bg-brand-lines/20 text-brand-text/60"
|
||||
: "border-brand-accent/50 bg-brand-lines/20 text-brand-text/60",
|
||||
? "grid-cols-[auto_minmax(0,70%)_28px]"
|
||||
: "grid-cols-[28px_minmax(0,70%)_auto]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="font-semibold text-brand-accent">
|
||||
{message.reply_to.sender?.username ?? "…"}
|
||||
{/* Row 1 – reply preview + ↩ icon + sender username */}
|
||||
{replyTo && (
|
||||
<div
|
||||
className={[
|
||||
"col-start-2 row-start-1 flex items-center gap-2",
|
||||
"opacity-50 transition-opacity hover:opacity-90",
|
||||
isOwn ? "justify-end" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<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">
|
||||
{(message.reply_to.content ?? "").slice(0, 80) || "…"}
|
||||
<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={[
|
||||
"rounded-2xl glass px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap",
|
||||
"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
|
||||
? "bg-brand-accent text-brand-bg rounded-br-sm"
|
||||
: "bg-brand-bgLight/70 text-brand-text rounded-bl-sm",
|
||||
? `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(" ")}
|
||||
>
|
||||
{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}
|
||||
</span>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (!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?.();
|
||||
}
|
||||
} 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>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]">
|
||||
<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 />
|
||||
<section className="flex h-full flex-col overflow-hidden">
|
||||
</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>
|
||||
|
||||
@@ -93,13 +93,20 @@ 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">
|
||||
{/* 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")}
|
||||
@@ -107,6 +114,7 @@ export default function SocialLayout() {
|
||||
<p className="text-xs">—</p>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user