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:
@@ -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\\)$\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
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);
|
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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user