Add chat settings modal & reply previews

Introduce a full ChatSettingsModal component for managing group chats (rename/icon upload, add/remove members, toggle moderators, leave/delete) and wire it into ChatRoomPage with a settings button. Add reply preview support end-to-end: new ReplyToSerializer and members_detail in backend serializers, updated frontend Message model (reply_to now contains preview object), and UI changes to Message to render reply excerpts. Improve socket handling to attach reply previews when available. Tweak backend Dockerfile to optionally install Windows/corporate CA bundle only if present and move pip install after copying source. Add Czech translations and small tooling/.claude config enhancements.
This commit is contained in:
2026-05-29 00:41:43 +02:00
parent 8269d044a2
commit 3d1965e5e6
8 changed files with 574 additions and 14 deletions

View File

@@ -8,7 +8,8 @@
"Bash(npx tsc *)", "Bash(npx tsc *)",
"Bash(npx eslint *)", "Bash(npx eslint *)",
"Bash(python -c ' *)", "Bash(python -c ' *)",
"PowerShell(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\frontend\\\\src\\\\components\\\\social\" -File -Recurse | Select-Object FullName, @{n='Lines';e={\\(Get-Content $_.FullName | Measure-Object -Line\\).Lines}} | Format-Table -AutoSize)" "PowerShell(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\frontend\\\\src\\\\components\\\\social\" -File -Recurse | Select-Object FullName, @{n='Lines';e={\\(Get-Content $_.FullName | Measure-Object -Line\\).Lines}} | Format-Table -AutoSize)",
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")"
] ]
} }
} }

View File

@@ -2,10 +2,6 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# Trust Windows/corporate root CAs before any network operations
COPY certs/windows-ca-bundle.crt /usr/local/share/ca-certificates/windows-ca-bundle.crt
RUN update-ca-certificates
# Install system dependencies including Node.js for yt-dlp JavaScript runtime # Install system dependencies including Node.js for yt-dlp JavaScript runtime
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
weasyprint \ weasyprint \
@@ -18,13 +14,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libmagic1 \ libmagic1 \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& update-ca-certificates \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# Trust Windows/corporate root CAs if present (optional, no-op when certs/ is absent)
RUN test -f certs/windows-ca-bundle.crt \
&& install -m 644 certs/windows-ca-bundle.crt /usr/local/share/ca-certificates/windows-ca-bundle.crt \
&& update-ca-certificates \
|| true
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 8000 EXPOSE 8000

View File

@@ -40,8 +40,18 @@ class MessageHistorySerializer(serializers.ModelSerializer):
read_only_fields = ['archived_at'] read_only_fields = ['archived_at']
class ReplyToSerializer(serializers.ModelSerializer):
sender = MessageSenderSerializer(read_only=True)
class Meta:
model = Message
fields = ['id', 'content', 'sender']
read_only_fields = ['id', 'content', 'sender']
class MessageSerializer(serializers.ModelSerializer): class MessageSerializer(serializers.ModelSerializer):
sender = MessageSenderSerializer(read_only=True) sender = MessageSenderSerializer(read_only=True)
reply_to = ReplyToSerializer(read_only=True)
media_files = MessageFileSerializer(many=True, read_only=True) media_files = MessageFileSerializer(many=True, read_only=True)
reactions = MessageReactionSerializer(many=True, read_only=True) reactions = MessageReactionSerializer(many=True, read_only=True)
@@ -79,6 +89,7 @@ class MessageSendSerializer(serializers.Serializer):
class ChatSerializer(serializers.ModelSerializer): class ChatSerializer(serializers.ModelSerializer):
unread_count = serializers.SerializerMethodField(read_only=True) unread_count = serializers.SerializerMethodField(read_only=True)
members_detail = MessageSenderSerializer(source='members', many=True, read_only=True)
@extend_schema_field(serializers.IntegerField()) @extend_schema_field(serializers.IntegerField())
def get_unread_count(self, obj): def get_unread_count(self, obj):
@@ -98,8 +109,9 @@ class ChatSerializer(serializers.ModelSerializer):
'id', 'chat_type', 'owner', 'name', 'id', 'chat_type', 'owner', 'name',
'icon', 'banner', 'members', 'moderators', 'icon', 'banner', 'members', 'moderators',
'hub', 'created_at', 'updated_at', 'unread_count', 'hub', 'created_at', 'updated_at', 'unread_count',
'members_detail',
] ]
read_only_fields = ['owner', 'created_at', 'updated_at', 'unread_count'] read_only_fields = ['owner', 'created_at', 'updated_at', 'unread_count', 'members_detail']
class ChatMemberSerializer(serializers.Serializer): class ChatMemberSerializer(serializers.Serializer):

View File

@@ -7,12 +7,18 @@ import type { MessageFile } from "./messageFile";
import type { MessageReaction } from "./messageReaction"; import type { MessageReaction } from "./messageReaction";
import type { MessageSender } from "./messageSender"; import type { MessageSender } from "./messageSender";
export interface ReplyToPreview {
readonly id: number;
content?: string;
readonly sender: MessageSender;
}
export interface Message { export interface Message {
readonly id: number; readonly id: number;
readonly chat: number; readonly chat: number;
readonly sender: MessageSender; readonly sender: MessageSender;
/** @nullable */ /** @nullable */
readonly reply_to: number | null; readonly reply_to: ReplyToPreview | null;
content?: string; content?: string;
readonly is_edited: boolean; readonly is_edited: boolean;
/** @nullable */ /** @nullable */

View File

@@ -0,0 +1,463 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FiX, FiSearch, FiTrash2, FiLogOut, FiUpload, FiShield, FiShieldOff } from "react-icons/fi";
import { useQueryClient } from "@tanstack/react-query";
import type { Chat } from "@/api/generated/private/models/chat";
import { ChatTypeEnum } from "@/api/generated/private/models/chatTypeEnum";
import {
getApiSocialChatsListQueryKey,
getApiSocialChatsRetrieveQueryKey,
useApiSocialChatsDestroy,
} from "@/api/generated/private/chat/chat";
import { useApiAccountUsersList } from "@/api/generated/private/account/account";
import type { CustomUser } from "@/api/generated/private/models";
import { useAuth } from "@/hooks/useAuth";
import { canManageChat } from "@/hooks/usePermissions";
import { privateApi } from "@/api/privateClient";
import Avatar from "@/components/ui/Avatar";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import Spinner from "@/components/ui/Spinner";
import FormErrorBanner from "@/components/ui/FormErrorBanner";
interface MemberDetail {
id: number;
username: string;
avatar: string | null;
}
type ChatWithDetails = Chat & { members_detail?: MemberDetail[] };
interface Props {
chat: ChatWithDetails;
open: boolean;
onClose: () => void;
}
export default function ChatSettingsModal({ chat, open, onClose }: Props) {
const { t } = useTranslation("social");
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const isGroup = chat.chat_type === ChatTypeEnum.GROUP;
const canManage = canManageChat(user, chat);
const isOwner = chat.owner === user?.id;
const members = chat.members_detail ?? [];
// --- Info section state ---
const [name, setName] = useState(chat.name ?? "");
const [iconFile, setIconFile] = useState<File | null>(null);
const [iconPreview, setIconPreview] = useState<string | null>(null);
const [savingInfo, setSavingInfo] = useState(false);
// --- Add member section ---
const [memberQuery, setMemberQuery] = useState("");
const [debouncedMemberQuery, setDebouncedMemberQuery] = useState("");
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
// --- Confirm leave/delete ---
const [confirmAction, setConfirmAction] = useState<"leave" | "delete" | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingUserId, setPendingUserId] = useState<number | null>(null);
const invalidateChat = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: getApiSocialChatsRetrieveQueryKey(String(chat.id)) });
void queryClient.invalidateQueries({ queryKey: getApiSocialChatsListQueryKey() });
}, [queryClient, chat.id]);
// Debounce member search
useEffect(() => {
const timer = setTimeout(() => setDebouncedMemberQuery(memberQuery.trim()), 300);
return () => clearTimeout(timer);
}, [memberQuery]);
// Close dropdown on outside click
useEffect(() => {
function handler(e: MouseEvent) {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
}
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Sync name state when chat changes
useEffect(() => { setName(chat.name ?? ""); }, [chat.name]);
const { data: searchData, isLoading: searchLoading } = useApiAccountUsersList(
debouncedMemberQuery.length >= 2 ? { username: debouncedMemberQuery } : undefined,
{ query: { enabled: debouncedMemberQuery.length >= 2 } },
);
const searchResults = (searchData?.results ?? []).filter(
(u) => !chat.members?.includes(u.id),
);
const { mutate: destroyChat, isPending: destroying } = useApiSocialChatsDestroy({
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: getApiSocialChatsListQueryKey() });
onClose();
navigate("/social/chats");
},
onError: () => setError(t("chat.settings.deleteError")),
},
});
function handleIconChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setIconFile(file);
setIconPreview(URL.createObjectURL(file));
}
async function handleSaveInfo() {
setSavingInfo(true);
setError(null);
try {
const fd = new FormData();
if (name.trim() !== (chat.name ?? "")) fd.append("name", name.trim());
if (iconFile) fd.append("icon", iconFile);
if ([...fd.keys()].length === 0) return;
await privateApi.patch(`/api/social/chats/${chat.id}/`, fd);
invalidateChat();
setIconFile(null);
} catch {
setError(t("chat.settings.saveError"));
} finally {
setSavingInfo(false);
}
}
async function handleAddMember(u: CustomUser) {
setError(null);
setPendingUserId(u.id);
try {
await privateApi.post(`/api/social/chats/${chat.id}/members/add/`, { user_id: u.id });
invalidateChat();
setMemberQuery("");
setShowDropdown(false);
} catch {
setError(t("chat.settings.addMemberError"));
} finally {
setPendingUserId(null);
}
}
async function handleRemoveMember(userId: number) {
setError(null);
setPendingUserId(userId);
try {
await privateApi.post(`/api/social/chats/${chat.id}/members/remove/`, { user_id: userId });
invalidateChat();
} catch {
setError(t("chat.settings.removeMemberError"));
} finally {
setPendingUserId(null);
}
}
async function handleToggleModerator(userId: number) {
setError(null);
const isMod = chat.moderators?.includes(userId) ?? false;
const url = isMod
? `/api/social/chats/${chat.id}/moderators/remove/`
: `/api/social/chats/${chat.id}/moderators/add/`;
setPendingUserId(userId);
try {
await privateApi.post(url, { user_id: userId });
invalidateChat();
} catch {
setError(t("chat.settings.modError"));
} finally {
setPendingUserId(null);
}
}
async function handleLeave() {
if (!user) return;
setError(null);
try {
await privateApi.post(`/api/social/chats/${chat.id}/members/remove/`, { user_id: user.id });
await queryClient.invalidateQueries({ queryKey: getApiSocialChatsListQueryKey() });
onClose();
navigate("/social/chats");
} catch {
setError(t("chat.settings.leaveError"));
}
}
if (!open) return null;
const infoChanged = name.trim() !== (chat.name ?? "") || iconFile !== null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label={t("chat.settings.title")}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-2xl border border-brand-lines/20 bg-brand-gradient shadow-2xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-brand-lines/15 px-5 py-4 shrink-0">
<h2 className="text-base font-semibold text-brand-text">{t("chat.settings.title")}</h2>
<button
type="button"
onClick={onClose}
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/15 hover:text-brand-text transition-colors"
aria-label={t("chat.settings.close")}
>
<FiX size={16} />
</button>
</div>
{/* Scrollable body */}
<div className="overflow-y-auto flex-1">
{/* ── Chat info ── */}
<section className="px-5 py-5 space-y-4 border-b border-brand-lines/10">
<div className="flex items-center gap-4">
{/* Icon */}
{isGroup && canManage ? (
<label className="relative cursor-pointer group shrink-0">
<Avatar
name={chat.name ?? ""}
src={iconPreview ?? chat.icon ?? undefined}
size={56}
/>
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity">
<FiUpload size={16} className="text-white" />
</span>
<input
type="file"
accept="image/*"
className="sr-only"
onChange={handleIconChange}
/>
</label>
) : (
<Avatar
name={chat.name ?? ""}
src={chat.icon ?? undefined}
size={56}
className="shrink-0"
/>
)}
{/* Name */}
<div className="flex-1 min-w-0">
{isGroup && canManage ? (
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("chat.settings.namePlaceholder")}
maxLength={255}
/>
) : (
<p className="text-sm font-semibold text-brand-text truncate">{chat.name}</p>
)}
<p className="text-xs text-brand-text/50 mt-0.5">
{isGroup ? t("chat.settings.typeGroup") : t("chat.settings.typeDM")}
{" · "}
{t("chat.settings.memberCount", { count: members.length })}
</p>
</div>
</div>
{isGroup && canManage && (
<Button
type="button"
variant="primary"
size="sm"
disabled={!infoChanged || savingInfo}
loading={savingInfo}
onClick={() => void handleSaveInfo()}
>
{t("chat.settings.saveInfo")}
</Button>
)}
</section>
{/* ── Members ── */}
<section className="px-5 py-4 space-y-3 border-b border-brand-lines/10">
<h3 className="text-xs font-semibold uppercase tracking-wider text-brand-text/50">
{t("chat.settings.membersTitle")}
</h3>
{/* Add member (GROUP + canManage only) */}
{isGroup && canManage && (
<div ref={searchRef} className="relative">
<div className="relative">
<FiSearch className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-brand-text/40" size={14} />
<input
value={memberQuery}
onChange={(e) => { setMemberQuery(e.target.value); setShowDropdown(true); }}
onFocus={() => memberQuery.length >= 2 && setShowDropdown(true)}
placeholder={t("chat.settings.addMemberPlaceholder")}
className="w-full rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 py-2 pl-8 pr-3 text-sm text-brand-text placeholder:text-brand-text/40 focus:border-brand-accent focus:outline-none focus:ring-2 focus:ring-brand-accent/30"
/>
</div>
{showDropdown && debouncedMemberQuery.length >= 2 && (
<div
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-44 overflow-y-auto rounded-xl border border-brand-lines/20 shadow-xl"
style={{ backgroundColor: "var(--c-background)" }}
>
{searchLoading && (
<div className="flex justify-center py-3"><Spinner size={16} /></div>
)}
{!searchLoading && searchResults.length === 0 && (
<p className="px-4 py-3 text-sm text-brand-text/50">{t("chat.settings.noUsers")}</p>
)}
{searchResults.map((u) => (
<button
key={u.id}
type="button"
disabled={pendingUserId === u.id}
onClick={() => void handleAddMember(u)}
className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-brand-lines/10 transition-colors disabled:opacity-50"
>
<Avatar name={u.username} src={u.avatar ?? undefined} size={26} />
<span className="text-sm text-brand-text">{u.username}</span>
{pendingUserId === u.id && <Spinner size={14} className="ml-auto" />}
</button>
))}
</div>
)}
</div>
)}
{/* Member list */}
<div className="space-y-1">
{members.map((m) => {
const isMod = chat.moderators?.includes(m.id) ?? false;
const isThisOwner = chat.owner === m.id;
const isMe = user?.id === m.id;
const isPending = pendingUserId === m.id;
return (
<div key={m.id} className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-brand-lines/8 transition-colors">
<Avatar name={m.username} src={m.avatar ?? undefined} size={32} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm font-medium text-brand-text truncate">{m.username}</span>
{isThisOwner && (
<span className="shrink-0 rounded-full bg-yellow-400/15 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-400">
{t("chat.settings.roleOwner")}
</span>
)}
{!isThisOwner && isMod && (
<span className="shrink-0 rounded-full bg-brand-accent/15 px-1.5 py-0.5 text-[10px] font-semibold text-brand-accent">
{t("chat.settings.roleMod")}
</span>
)}
</div>
</div>
{/* Actions — shown only to canManage, not for owner, not for self (except leave is elsewhere) */}
{canManage && !isThisOwner && !isMe && (
<div className="flex items-center gap-1 shrink-0">
{isPending ? (
<Spinner size={14} />
) : (
<>
{/* Toggle mod (owner only) */}
{isOwner && (
<button
type="button"
title={isMod ? t("chat.settings.removeMod") : t("chat.settings.makeMod")}
onClick={() => void handleToggleModerator(m.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-brand-text/50 hover:bg-brand-lines/15 hover:text-brand-accent transition-colors"
>
{isMod ? <FiShieldOff size={13} /> : <FiShield size={13} />}
</button>
)}
{/* Remove member */}
<button
type="button"
title={t("chat.settings.removeMember")}
onClick={() => void handleRemoveMember(m.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-brand-text/50 hover:bg-red-500/15 hover:text-red-400 transition-colors"
>
<FiX size={13} />
</button>
</>
)}
</div>
)}
</div>
);
})}
</div>
</section>
{/* ── Danger zone ── */}
<section className="px-5 py-4 space-y-2">
<FormErrorBanner message={error} />
{confirmAction ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 space-y-3">
<p className="text-sm text-brand-text">
{confirmAction === "leave" ? t("chat.settings.confirmLeave") : t("chat.settings.confirmDelete")}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="danger"
size="sm"
loading={destroying}
onClick={() => {
if (confirmAction === "leave") void handleLeave();
else destroyChat({ id: String(chat.id) });
}}
>
{t("chat.settings.confirm")}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setConfirmAction(null)}>
{t("chat.settings.cancel")}
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-2">
{!isOwner && (
<button
type="button"
onClick={() => setConfirmAction("leave")}
className="flex items-center gap-2 rounded-xl px-3 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors w-full"
>
<FiLogOut size={15} />
{t("chat.settings.leave")}
</button>
)}
{canManage && (
<button
type="button"
onClick={() => setConfirmAction("delete")}
className="flex items-center gap-2 rounded-xl px-3 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors w-full"
>
<FiTrash2 size={15} />
{t("chat.settings.delete")}
</button>
)}
</div>
)}
</section>
</div>
</div>
</div>
);
}

View File

@@ -31,12 +31,29 @@ export default function Message({ message, chat, onReply, onReact }: Props) {
const canDelete = canDeleteMessage(user, message, chat); const canDelete = canDeleteMessage(user, message, chat);
return ( return (
<div className={`group flex gap-2 px-4 py-1.5 ${isOwn ? "flex-row-reverse" : ""}`}> <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} /> <Avatar name={sender?.username} src={sender?.avatar} size={28} />
<div className={`flex max-w-[70%] flex-col ${isOwn ? "items-end" : "items-start"}`}> <div className={`flex max-w-[70%] flex-col gap-1 ${isOwn ? "items-end" : "items-start"}`}>
{message.reply_to && (
<div <div
className={[ className={[
"rounded-2xl px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap", "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
className={[
"rounded-2xl glass px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap",
isOwn isOwn
? "bg-brand-accent text-brand-bg rounded-br-sm" ? "bg-brand-accent text-brand-bg rounded-br-sm"
: "bg-brand-bgLight/70 text-brand-text rounded-bl-sm", : "bg-brand-bgLight/70 text-brand-text rounded-bl-sm",

View File

@@ -99,6 +99,37 @@
"more": "Více možností", "more": "Více možností",
"delete": "Smazat zprávu", "delete": "Smazat zprávu",
"edit": "Upravit zprávu" "edit": "Upravit zprávu"
},
"settings": {
"title": "Nastavení chatu",
"close": "Zavřít",
"typeGroup": "Skupinový chat",
"typeDM": "Přímá zpráva",
"memberCount_one": "{{count}} člen",
"memberCount_few": "{{count}} členové",
"memberCount_other": "{{count}} členů",
"namePlaceholder": "Název skupiny...",
"saveInfo": "Uložit změny",
"saveError": "Změny se nepodařilo uložit.",
"membersTitle": "Členové",
"addMemberPlaceholder": "Přidat člena...",
"noUsers": "Žádní uživatelé nenalezeni.",
"addMemberError": "Nepodařilo se přidat člena.",
"removeMemberError": "Nepodařilo se odebrat člena.",
"removeMember": "Odebrat ze chatu",
"modError": "Nepodařilo se změnit moderátora.",
"makeMod": "Přidat jako moderátora",
"removeMod": "Odebrat moderátora",
"roleOwner": "Vlastník",
"roleMod": "Moderátor",
"leave": "Opustit chat",
"delete": "Smazat chat",
"leaveError": "Nepodařilo se opustit chat.",
"deleteError": "Nepodařilo se smazat chat.",
"confirmLeave": "Opravdu chcete opustit tento chat?",
"confirmDelete": "Opravdu chcete smazat tento chat? Tato akce je nevratná.",
"confirm": "Potvrdit",
"cancel": "Zrušit"
} }
}, },
"hub": { "hub": {

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { FiMoreVertical } from "react-icons/fi";
import type { Message as MessageModel } from "@/api/generated/private/models/message"; import type { Message as MessageModel } from "@/api/generated/private/models/message";
import { getApiSocialChatsListQueryKey, useApiSocialChatsRetrieve } from "@/api/generated/private/chat/chat"; import { getApiSocialChatsListQueryKey, useApiSocialChatsRetrieve } from "@/api/generated/private/chat/chat";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
@@ -11,6 +12,7 @@ import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
import { messagesQueryKey, type CursorPaginated } from "@/api/social/feed"; import { messagesQueryKey, type CursorPaginated } from "@/api/social/feed";
import Message from "@/components/social/chat/Message"; import Message from "@/components/social/chat/Message";
import MessageComposer from "@/components/social/chat/MessageComposer"; import MessageComposer from "@/components/social/chat/MessageComposer";
import ChatSettingsModal from "@/components/social/chat/ChatSettingsModal";
import Spinner from "@/components/ui/Spinner"; import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState"; import EmptyState from "@/components/ui/EmptyState";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
@@ -33,6 +35,7 @@ export default function ChatRoomPage() {
const [replyTo, setReplyTo] = useState<MessageModel | null>(null); const [replyTo, setReplyTo] = useState<MessageModel | null>(null);
const [typingUsers, setTypingUsers] = useState<string[]>([]); const [typingUsers, setTypingUsers] = useState<string[]>([]);
const [settingsOpen, setSettingsOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement | null>(null); const bottomRef = useRef<HTMLDivElement | null>(null);
// Top sentinel triggers loading older messages (scroll-back). // Top sentinel triggers loading older messages (scroll-back).
@@ -85,11 +88,21 @@ export default function ChatRoomPage() {
const handleSocketEvent = useCallback( const handleSocketEvent = useCallback(
(event: ChatSocketEvent) => { (event: ChatSocketEvent) => {
if (event.type === "new_chat_message" || event.type === "new_reply_chat_message") { 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) {
const cached = queryClient.getQueryData<{ pages: CursorPaginated<MessageModel>[] }>(
messagesQueryKey(chatId),
);
const found = cached?.pages.flatMap((p) => p.results).find((m) => m.id === event.reply_to_id);
replyTo = found
? { id: found.id, content: found.content, sender: found.sender }
: { id: event.reply_to_id, content: undefined, sender: { id: 0, username: "…", avatar: null } };
}
appendMessage({ appendMessage({
id: event.message_id, id: event.message_id,
chat: chatId, chat: chatId,
sender: { id: event.sender_id, username: event.sender, avatar: event.sender_avatar } as never, sender: { id: event.sender_id, username: event.sender, avatar: event.sender_avatar } as never,
reply_to: event.type === "new_reply_chat_message" ? event.reply_to_id : null, reply_to: replyTo,
content: event.message, content: event.message,
is_edited: false, is_edited: false,
edited_at: null, edited_at: null,
@@ -160,8 +173,24 @@ export default function ChatRoomPage() {
</div> </div>
)} )}
</div> </div>
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/15 hover:text-brand-text transition-colors"
aria-label={t("chat.settings.title")}
>
<FiMoreVertical size={17} />
</button>
</header> </header>
{chat && (
<ChatSettingsModal
chat={chat as Parameters<typeof ChatSettingsModal>[0]["chat"]}
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
)}
<div className="flex-1 overflow-y-auto py-2"> <div className="flex-1 overflow-y-auto py-2">
{hasNextPage && ( {hasNextPage && (
<div ref={topSentinelRef} className="flex justify-center py-2"> <div ref={topSentinelRef} className="flex justify-center py-2">