From 3d1965e5e60ef74826379f3d6ab2d6d14e0efd61 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Fri, 29 May 2026 00:41:43 +0200 Subject: [PATCH] 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. --- .claude/settings.local.json | 3 +- backend/Dockerfile | 15 +- backend/social/chat/serializers.py | 14 +- .../api/generated/private/models/message.ts | 8 +- .../social/chat/ChatSettingsModal.tsx | 463 ++++++++++++++++++ .../src/components/social/chat/Message.tsx | 23 +- frontend/src/i18n/locales/cs/social.json | 31 ++ .../src/pages/social/chat/ChatRoomPage.tsx | 31 +- 8 files changed, 574 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/social/chat/ChatSettingsModal.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f6e1b92..8359675 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,8 @@ "Bash(npx tsc *)", "Bash(npx eslint *)", "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\\)$\")" ] } } diff --git a/backend/Dockerfile b/backend/Dockerfile index 23cf431..691589d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,10 +2,6 @@ FROM python:3.12-slim 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 RUN apt-get update && apt-get install -y --no-install-recommends \ weasyprint \ @@ -18,13 +14,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libmagic1 \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ - && update-ca-certificates \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - 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 diff --git a/backend/social/chat/serializers.py b/backend/social/chat/serializers.py index 4705b1f..81cec0d 100644 --- a/backend/social/chat/serializers.py +++ b/backend/social/chat/serializers.py @@ -40,8 +40,18 @@ class MessageHistorySerializer(serializers.ModelSerializer): 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): sender = MessageSenderSerializer(read_only=True) + reply_to = ReplyToSerializer(read_only=True) media_files = MessageFileSerializer(many=True, read_only=True) reactions = MessageReactionSerializer(many=True, read_only=True) @@ -79,6 +89,7 @@ class MessageSendSerializer(serializers.Serializer): class ChatSerializer(serializers.ModelSerializer): unread_count = serializers.SerializerMethodField(read_only=True) + members_detail = MessageSenderSerializer(source='members', many=True, read_only=True) @extend_schema_field(serializers.IntegerField()) def get_unread_count(self, obj): @@ -98,8 +109,9 @@ class ChatSerializer(serializers.ModelSerializer): 'id', 'chat_type', 'owner', 'name', 'icon', 'banner', 'members', 'moderators', '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): diff --git a/frontend/src/api/generated/private/models/message.ts b/frontend/src/api/generated/private/models/message.ts index 21ae00b..d822c44 100644 --- a/frontend/src/api/generated/private/models/message.ts +++ b/frontend/src/api/generated/private/models/message.ts @@ -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 */ diff --git a/frontend/src/components/social/chat/ChatSettingsModal.tsx b/frontend/src/components/social/chat/ChatSettingsModal.tsx new file mode 100644 index 0000000..b8f411b --- /dev/null +++ b/frontend/src/components/social/chat/ChatSettingsModal.tsx @@ -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(null); + const [iconPreview, setIconPreview] = useState(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(null); + + // --- Confirm leave/delete --- + const [confirmAction, setConfirmAction] = useState<"leave" | "delete" | null>(null); + + const [error, setError] = useState(null); + const [pendingUserId, setPendingUserId] = useState(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) { + 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 ( +
+
+ +
+ {/* Header */} +
+

{t("chat.settings.title")}

+ +
+ + {/* Scrollable body */} +
+ {/* ── Chat info ── */} +
+
+ {/* Icon */} + {isGroup && canManage ? ( + + ) : ( + + )} + + {/* Name */} +
+ {isGroup && canManage ? ( + setName(e.target.value)} + placeholder={t("chat.settings.namePlaceholder")} + maxLength={255} + /> + ) : ( +

{chat.name}

+ )} +

+ {isGroup ? t("chat.settings.typeGroup") : t("chat.settings.typeDM")} + {" · "} + {t("chat.settings.memberCount", { count: members.length })} +

+
+
+ + {isGroup && canManage && ( + + )} +
+ + {/* ── Members ── */} +
+

+ {t("chat.settings.membersTitle")} +

+ + {/* Add member (GROUP + canManage only) */} + {isGroup && canManage && ( +
+
+ + { 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" + /> +
+ {showDropdown && debouncedMemberQuery.length >= 2 && ( +
+ {searchLoading && ( +
+ )} + {!searchLoading && searchResults.length === 0 && ( +

{t("chat.settings.noUsers")}

+ )} + {searchResults.map((u) => ( + + ))} +
+ )} +
+ )} + + {/* Member list */} +
+ {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 ( +
+ +
+
+ {m.username} + {isThisOwner && ( + + {t("chat.settings.roleOwner")} + + )} + {!isThisOwner && isMod && ( + + {t("chat.settings.roleMod")} + + )} +
+
+ + {/* Actions — shown only to canManage, not for owner, not for self (except leave is elsewhere) */} + {canManage && !isThisOwner && !isMe && ( +
+ {isPending ? ( + + ) : ( + <> + {/* Toggle mod (owner only) */} + {isOwner && ( + + )} + {/* Remove member */} + + + )} +
+ )} +
+ ); + })} +
+
+ + {/* ── Danger zone ── */} +
+ + + {confirmAction ? ( +
+

+ {confirmAction === "leave" ? t("chat.settings.confirmLeave") : t("chat.settings.confirmDelete")} +

+
+ + +
+
+ ) : ( +
+ {!isOwner && ( + + )} + {canManage && ( + + )} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/social/chat/Message.tsx b/frontend/src/components/social/chat/Message.tsx index fc60951..85f91f5 100644 --- a/frontend/src/components/social/chat/Message.tsx +++ b/frontend/src/components/social/chat/Message.tsx @@ -31,12 +31,29 @@ export default function Message({ message, chat, onReply, onReact }: Props) { const canDelete = canDeleteMessage(user, message, chat); return ( -
+
-
+
+ {message.reply_to && ( +
+ + {message.reply_to.sender?.username ?? "…"} + + + {(message.reply_to.content ?? "").slice(0, 80) || "…"} + +
+ )}
(null); const [typingUsers, setTypingUsers] = useState([]); + const [settingsOpen, setSettingsOpen] = useState(false); const bottomRef = useRef(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[] }>( + 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() {
)}
+ + {chat && ( + [0]["chat"]} + open={settingsOpen} + onClose={() => setSettingsOpen(false)} + /> + )} +
{hasNextPage && (