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:
@@ -7,12 +7,18 @@ import type { MessageFile } from "./messageFile";
|
||||
import type { MessageReaction } from "./messageReaction";
|
||||
import type { MessageSender } from "./messageSender";
|
||||
|
||||
export interface ReplyToPreview {
|
||||
readonly id: number;
|
||||
content?: string;
|
||||
readonly sender: MessageSender;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
readonly id: number;
|
||||
readonly chat: number;
|
||||
readonly sender: MessageSender;
|
||||
/** @nullable */
|
||||
readonly reply_to: number | null;
|
||||
readonly reply_to: ReplyToPreview | null;
|
||||
content?: string;
|
||||
readonly is_edited: boolean;
|
||||
/** @nullable */
|
||||
|
||||
463
frontend/src/components/social/chat/ChatSettingsModal.tsx
Normal file
463
frontend/src/components/social/chat/ChatSettingsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -31,12 +31,29 @@ export default function Message({ message, chat, onReply, onReact }: Props) {
|
||||
const canDelete = canDeleteMessage(user, message, chat);
|
||||
|
||||
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} />
|
||||
<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
|
||||
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
|
||||
className={[
|
||||
"rounded-2xl px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap",
|
||||
"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",
|
||||
|
||||
@@ -99,6 +99,37 @@
|
||||
"more": "Více možností",
|
||||
"delete": "Smazat 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": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import type { Message as MessageModel } from "@/api/generated/private/models/message";
|
||||
import { getApiSocialChatsListQueryKey, useApiSocialChatsRetrieve } from "@/api/generated/private/chat/chat";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -11,6 +12,7 @@ import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
|
||||
import { messagesQueryKey, type CursorPaginated } from "@/api/social/feed";
|
||||
import Message from "@/components/social/chat/Message";
|
||||
import MessageComposer from "@/components/social/chat/MessageComposer";
|
||||
import ChatSettingsModal from "@/components/social/chat/ChatSettingsModal";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import EmptyState from "@/components/ui/EmptyState";
|
||||
import Avatar from "@/components/ui/Avatar";
|
||||
@@ -33,6 +35,7 @@ export default function ChatRoomPage() {
|
||||
|
||||
const [replyTo, setReplyTo] = useState<MessageModel | null>(null);
|
||||
const [typingUsers, setTypingUsers] = useState<string[]>([]);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Top sentinel triggers loading older messages (scroll-back).
|
||||
@@ -85,11 +88,21 @@ export default function ChatRoomPage() {
|
||||
const handleSocketEvent = useCallback(
|
||||
(event: ChatSocketEvent) => {
|
||||
if (event.type === "new_chat_message" || event.type === "new_reply_chat_message") {
|
||||
let replyTo: MessageModel["reply_to"] = null;
|
||||
if (event.type === "new_reply_chat_message" && event.reply_to_id != null) {
|
||||
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({
|
||||
id: event.message_id,
|
||||
chat: chatId,
|
||||
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,
|
||||
is_edited: false,
|
||||
edited_at: null,
|
||||
@@ -160,8 +173,24 @@ export default function ChatRoomPage() {
|
||||
</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>
|
||||
|
||||
{chat && (
|
||||
<ChatSettingsModal
|
||||
chat={chat as Parameters<typeof ChatSettingsModal>[0]["chat"]}
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{hasNextPage && (
|
||||
<div ref={topSentinelRef} className="flex justify-center py-2">
|
||||
|
||||
Reference in New Issue
Block a user