mirror of
https://github.com/StanGirard/quivr.git
synced 2024-12-23 19:32:30 +03:00
feat: add chat view new design (#1897)
Issue: https://github.com/StanGirard/quivr/issues/1888 - Add Spinner when history is loading - Change chat messages fetching logic - Add cha view new design Demo: https://github.com/StanGirard/quivr/assets/63923024/c4341ccf-bacd-4720-9aa1-127dd557a75c
This commit is contained in:
parent
992c67a2b9
commit
e2c1a027b0
@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PropsWithChildren, useEffect } from "react";
|
||||
|
||||
import { Menu } from "@/lib/components/Menu/Menu";
|
||||
import { useOutsideClickListener } from "@/lib/components/Menu/hooks/useOutsideClickListener";
|
||||
import { NotificationBanner } from "@/lib/components/NotificationBanner";
|
||||
import { BrainProvider } from "@/lib/context";
|
||||
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
|
||||
@ -18,6 +19,7 @@ import "../lib/config/LocaleConfig/i18n";
|
||||
const App = ({ children }: PropsWithChildren): JSX.Element => {
|
||||
const { fetchAllBrains, fetchDefaultBrain, fetchPublicPrompts } =
|
||||
useBrainContext();
|
||||
const { onClickOutside } = useOutsideClickListener();
|
||||
const { session } = useSupabase();
|
||||
|
||||
usePageTracking();
|
||||
@ -35,7 +37,9 @@ const App = ({ children }: PropsWithChildren): JSX.Element => {
|
||||
<NotificationBanner />
|
||||
<div className="relative h-full w-full flex justify-stretch items-stretch overflow-auto">
|
||||
<Menu />
|
||||
<div className="flex-1">{children}</div>
|
||||
<div onClick={onClickOutside} className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
<UpdateMetadata />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -20,17 +20,34 @@ import {
|
||||
import SelectedChatPage from "../page";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
vi.mock("@/lib/context/ChatProvider/ChatProvider", () => ({
|
||||
ChatContext: ChatContextMock,
|
||||
ChatProvider: ChatProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/context/ChatsProvider/hooks/useChatsContext", () => ({
|
||||
useChatsContext: () => ({
|
||||
allChats: [
|
||||
{
|
||||
chat_id: 1,
|
||||
name: "Chat 1",
|
||||
creation_time: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
chat_id: 2,
|
||||
name: "Chat 2",
|
||||
creation_time: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
deleteChat: vi.fn(),
|
||||
setAllChats: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: vi.fn() }),
|
||||
useParams: () => ({ chatId: "1" }),
|
||||
usePathname: () => "/chat/1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/context/SupabaseProvider/supabase-provider", () => ({
|
||||
SupabaseContext: SupabaseContextMock,
|
||||
}));
|
||||
@ -38,13 +55,11 @@ vi.mock("@/lib/context/SupabaseProvider/supabase-provider", () => ({
|
||||
vi.mock("@/lib/context/BrainProvider/brain-provider", () => ({
|
||||
BrainContext: BrainContextMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/chat/useChatApi", () => ({
|
||||
useChatApi: () => ({
|
||||
getHistory: () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/hooks", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/hooks")>(
|
||||
"@/lib/hooks"
|
||||
@ -59,7 +74,6 @@ vi.mock("@/lib/hooks", async () => {
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
|
||||
"@tanstack/react-query"
|
||||
@ -72,7 +86,6 @@ vi.mock("@tanstack/react-query", async () => {
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
"../components/ActionsBar/components/ChatInput/components/ChatEditor/ChatEditor",
|
||||
() => ({
|
||||
@ -80,6 +93,9 @@ vi.mock(
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("../hooks/useChatNotificationsSync", () => ({
|
||||
useChatNotificationsSync: vi.fn(),
|
||||
}));
|
||||
describe("Chat page", () => {
|
||||
it("should render chat page correctly", () => {
|
||||
const { getByTestId } = render(
|
||||
|
@ -30,7 +30,7 @@ export const Button = forwardRef(
|
||||
<div className="flex flex-row justify-between w-full items-center">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{startIcon}
|
||||
<span className="hidden sm:block">{label}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{endIcon}
|
||||
</div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@ -21,7 +21,6 @@ vi.mock("@/lib/context/SupabaseProvider/supabase-provider", () => ({
|
||||
|
||||
import { ChatsList } from "../index";
|
||||
|
||||
const getChatsMock = vi.fn(() => []);
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
vi.mock("next/navigation", async () => {
|
||||
@ -121,30 +120,4 @@ describe("ChatsList", () => {
|
||||
const chatItems = screen.getAllByTestId("chats-list-item");
|
||||
expect(chatItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should call getChats when the component mounts", async () => {
|
||||
vi.mock("@/lib/api/chat/useChatApi", () => ({
|
||||
useChatApi: () => ({
|
||||
getChats: () => getChatsMock(),
|
||||
}),
|
||||
}));
|
||||
|
||||
await act(() =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<KnowledgeToFeedProvider>
|
||||
<ChatProviderMock>
|
||||
<BrainProviderMock>
|
||||
<SideBarProvider>
|
||||
<ChatsList />
|
||||
</SideBarProvider>
|
||||
</BrainProviderMock>
|
||||
</ChatProviderMock>
|
||||
</KnowledgeToFeedProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
);
|
||||
|
||||
expect(getChatsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
@ -4,12 +4,8 @@ import { ChatHistory } from "@/lib/components/ChatHistory/ChatHistory";
|
||||
import { useOnboarding } from "@/lib/hooks/useOnboarding";
|
||||
|
||||
import { WelcomeChat } from "./components/WelcomeChat";
|
||||
import { useChatNotificationsSync } from "./hooks/useChatNotificationsSync";
|
||||
import { useChatsList } from "./hooks/useChatsList";
|
||||
|
||||
export const ChatsList = (): JSX.Element => {
|
||||
useChatsList();
|
||||
useChatNotificationsSync();
|
||||
const { shouldDisplayWelcomeChat } = useOnboarding();
|
||||
|
||||
return (
|
||||
|
@ -7,9 +7,9 @@ import { useNotificationApi } from "@/lib/api/notification/useNotificationApi";
|
||||
import { useChatContext } from "@/lib/context";
|
||||
import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider/hooks/useKnowledgeToFeedContext";
|
||||
|
||||
import { getChatNotificationsQueryKey } from "../../../../../../../../../../../utils/getChatNotificationsQueryKey";
|
||||
import { getMessagesFromChatItems } from "../../../../../../../../../../../utils/getMessagesFromChatItems";
|
||||
import { getNotificationsFromChatItems } from "../../../../../../../../../../../utils/getNotificationsFromChatItems";
|
||||
import { getChatNotificationsQueryKey } from "../utils/getChatNotificationsQueryKey";
|
||||
import { getMessagesFromChatItems } from "../utils/getMessagesFromChatItems";
|
||||
import { getNotificationsFromChatItems } from "../utils/getNotificationsFromChatItems";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useChatNotificationsSync = () => {
|
@ -11,7 +11,7 @@ import { useToast } from "@/lib/hooks";
|
||||
export const useChatsList = () => {
|
||||
const { t } = useTranslation(["chat"]);
|
||||
|
||||
const { setAllChats } = useChatsContext();
|
||||
const { setAllChats, setIsLoading } = useChatsContext();
|
||||
const { publish } = useToast();
|
||||
const { getChats } = useChatApi();
|
||||
|
||||
@ -29,7 +29,7 @@ export const useChatsList = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const { data: chats } = useQuery({
|
||||
const { data: chats, isLoading } = useQuery({
|
||||
queryKey: [CHATS_DATA_KEY],
|
||||
queryFn: fetchAllChats,
|
||||
});
|
||||
@ -37,4 +37,8 @@ export const useChatsList = () => {
|
||||
useEffect(() => {
|
||||
setAllChats(chats ?? []);
|
||||
}, [chats]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(isLoading);
|
||||
}, [isLoading]);
|
||||
};
|
@ -1,19 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useMenuWidth } from "@/lib/components/Menu/hooks/useMenuWidth";
|
||||
import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider/hooks/useKnowledgeToFeedContext";
|
||||
import { useCustomDropzone } from "@/lib/hooks/useDropzone";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ActionsBar } from "./components/ActionsBar";
|
||||
import { ChatDialogueArea } from "./components/ChatDialogueArea/ChatDialogue";
|
||||
import { useChatNotificationsSync } from "./hooks/useChatNotificationsSync";
|
||||
import { useChatsList } from "./hooks/useChatsList";
|
||||
|
||||
const SelectedChatPage = (): JSX.Element => {
|
||||
const { getRootProps } = useCustomDropzone();
|
||||
const { shouldDisplayFeedCard } = useKnowledgeToFeedContext();
|
||||
const { staticMenuWidth } = useMenuWidth();
|
||||
|
||||
useChatsList();
|
||||
useChatNotificationsSync();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1">
|
||||
<div
|
||||
className={`flex flex-col flex-1 items-center justify-stretch w-full h-full overflow-hidden ${shouldDisplayFeedCard ? "bg-chat-bg-gray" : "bg-white"
|
||||
} dark:bg-black transition-colors ease-out duration-500`}
|
||||
className={cn(
|
||||
"flex flex-col flex-1 items-center justify-stretch w-full h-full overflow-hidden",
|
||||
shouldDisplayFeedCard ? "bg-chat-bg-gray" : "bg-tertiary",
|
||||
"dark:bg-black transition-colors ease-out duration-500"
|
||||
)}
|
||||
data-testid="chat-page"
|
||||
{...getRootProps()}
|
||||
>
|
||||
@ -26,6 +38,8 @@ const SelectedChatPage = (): JSX.Element => {
|
||||
<ActionsBar />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full bg-highlight" style={{ width: staticMenuWidth }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -9,9 +9,10 @@ import {
|
||||
isWithinLast7Days,
|
||||
isYesterday,
|
||||
} from "./utils";
|
||||
import Spinner from "../ui/Spinner";
|
||||
|
||||
export const ChatHistory = (): JSX.Element => {
|
||||
const { allChats } = useChatsContext();
|
||||
const { allChats, isLoading } = useChatsContext();
|
||||
const { t } = useTranslation("chat");
|
||||
const todayChats = allChats.filter((chat) =>
|
||||
isToday(new Date(chat.creation_time))
|
||||
@ -26,6 +27,14 @@ export const ChatHistory = (): JSX.Element => {
|
||||
isWithinLast30Days(new Date(chat.creation_time))
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center align-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="chats-list-items"
|
||||
|
@ -11,10 +11,12 @@ import { MenuHeader } from "./components/MenuHeader";
|
||||
import { ParametersButton } from "./components/ParametersButton";
|
||||
import { ProfileButton } from "./components/ProfileButton";
|
||||
import { UpgradeToPlus } from "./components/UpgradeToPlus";
|
||||
import { useMenuWidth } from "./hooks/useMenuWidth";
|
||||
|
||||
export const Menu = (): JSX.Element => {
|
||||
const pathname = usePathname() ?? "";
|
||||
|
||||
const { staticMenuWidth } = useMenuWidth();
|
||||
if (nonProtectedPaths.includes(pathname)) {
|
||||
return <></>;
|
||||
}
|
||||
@ -27,7 +29,10 @@ export const Menu = (): JSX.Element => {
|
||||
|
||||
return (
|
||||
<MotionConfig transition={{ mass: 1, damping: 10, duration: 0.2 }}>
|
||||
<div className="flex flex-col fixed sm:sticky top-0 left-0 h-full overflow-visible z-30 border-r border-black/10 dark:border-white/25 bg-white dark:bg-black">
|
||||
<div
|
||||
className="flex flex-col fixed sm:sticky top-0 left-0 h-full overflow-visible z-30 border-r border-black/10 dark:border-white/25 bg-highlight bg-highlight"
|
||||
style={{ width: staticMenuWidth }}
|
||||
>
|
||||
<AnimatedDiv>
|
||||
<div className="flex flex-col flex-1 p-4 gap-4">
|
||||
<MenuHeader />
|
||||
|
@ -2,25 +2,28 @@ import { motion } from "framer-motion";
|
||||
|
||||
import { useSideBarContext } from "@/lib/context/SidebarProvider/hooks/useSideBarContext";
|
||||
|
||||
import { useMenuWidth } from "../hooks/useMenuWidth";
|
||||
|
||||
type AnimatedDivProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export const AnimatedDiv = ({ children }: AnimatedDivProps): JSX.Element => {
|
||||
const { isOpened } = useSideBarContext();
|
||||
const { OPENED_MENU_WIDTH } = useMenuWidth();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{
|
||||
width: isOpened ? "260px" : "0px",
|
||||
width: isOpened ? OPENED_MENU_WIDTH : "0px",
|
||||
}}
|
||||
animate={{
|
||||
width: isOpened ? "260px" : "0px",
|
||||
width: isOpened ? OPENED_MENU_WIDTH : 0,
|
||||
opacity: isOpened ? 1 : 0.5,
|
||||
boxShadow: isOpened
|
||||
? "10px 10px 16px rgba(0, 0, 0, 0)"
|
||||
: "10px 10px 16px rgba(0, 0, 0, 0.5)",
|
||||
}}
|
||||
className={"overflow-hidden flex flex-col flex-1"}
|
||||
className={"overflow-hidden flex flex-col flex-1 bg-white"}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
|
23
frontend/lib/components/Menu/hooks/useMenuWidth.ts
Normal file
23
frontend/lib/components/Menu/hooks/useMenuWidth.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useDevice } from "@/lib/hooks/useDevice";
|
||||
|
||||
const OPENED_MENU_WIDTH = 260;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useMenuWidth = () => {
|
||||
const pathname = usePathname() ?? "";
|
||||
const { isMobile } = useDevice();
|
||||
|
||||
const isStaticSideBarActivated = !isMobile;
|
||||
|
||||
const shouldAddFixedPadding = pathname.startsWith("/chat");
|
||||
|
||||
const staticMenuWidth =
|
||||
shouldAddFixedPadding && isStaticSideBarActivated ? OPENED_MENU_WIDTH : 0;
|
||||
|
||||
return {
|
||||
OPENED_MENU_WIDTH,
|
||||
staticMenuWidth,
|
||||
};
|
||||
};
|
@ -0,0 +1,18 @@
|
||||
import { useSideBarContext } from "@/lib/context/SidebarProvider/hooks/useSideBarContext";
|
||||
import { useDevice } from "@/lib/hooks/useDevice";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useOutsideClickListener = () => {
|
||||
const { isOpened, setIsOpened } = useSideBarContext();
|
||||
const { isMobile } = useDevice();
|
||||
|
||||
const onClickOutside = () => {
|
||||
if (isOpened && isMobile) {
|
||||
setIsOpened(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
onClickOutside,
|
||||
};
|
||||
};
|
@ -8,6 +8,8 @@ type ChatsContextType = {
|
||||
allChats: ChatEntity[];
|
||||
//set setAllChats is from the useState hook so it can take a function as params
|
||||
setAllChats: React.Dispatch<React.SetStateAction<ChatEntity[]>>;
|
||||
isLoading: boolean;
|
||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const ChatsContext = createContext<ChatsContextType | undefined>(
|
||||
@ -20,12 +22,15 @@ export const ChatsProvider = ({
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => {
|
||||
const [allChats, setAllChats] = useState<ChatEntity[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<ChatsContext.Provider
|
||||
value={{
|
||||
allChats,
|
||||
setAllChats,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
@ -7,7 +7,7 @@ export const useChatsContext = () => {
|
||||
const context = useContext(ChatsContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useChatsStore must be used inside ChatsProvider");
|
||||
throw new Error("useChatsContext must be used inside ChatsProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
|
@ -51,7 +51,7 @@
|
||||
"shortcut_go_to_shortcuts": "CMDK: Go to shortcuts",
|
||||
"shortcut_go_to_user_page": "CMDU: Go to user page",
|
||||
"shortcut_manage_brains": "CMDB: Manage your brains",
|
||||
"shortcut_select_brain": "@: Select a brain to talk",
|
||||
"shortcut_select_brain": "@: Select a brain",
|
||||
"shortcut_select_file": "/: Select a file to talk to",
|
||||
"subtitle": "Talk to a language model about your uploaded data",
|
||||
"thinking": "Thinking...",
|
||||
|
@ -51,7 +51,7 @@
|
||||
"shortcut_go_to_shortcuts": "CMDK: Ir a los atajos",
|
||||
"shortcut_go_to_user_page": "CMDU: Ir a la página de usuario",
|
||||
"shortcut_manage_brains": "CMDB: Administrar tus cerebros",
|
||||
"shortcut_select_brain": "@: Seleccionar un cerebro para hablar",
|
||||
"shortcut_select_brain": "@: Seleccionar un cerebro",
|
||||
"shortcut_select_file": "/: Seleccionar un archivo para hablar",
|
||||
"subtitle": "Habla con un modelo de lenguaje acerca de tus datos subidos",
|
||||
"thinking": "Pensando...",
|
||||
|
@ -51,7 +51,7 @@
|
||||
"shortcut_go_to_shortcuts": "CMDK: Accéder aux raccourcis",
|
||||
"shortcut_go_to_user_page": "CMDU: Accéder à la page utilisateur",
|
||||
"shortcut_manage_brains": "CMDB: Gérer vos cerveaux",
|
||||
"shortcut_select_brain": "@: Sélectionner un cerveau pour discuter",
|
||||
"shortcut_select_brain": "@: Sélectionnez un cerveau",
|
||||
"shortcut_select_file": "/: Sélectionner un fichier pour discuter",
|
||||
"subtitle": "Parlez à un modèle linguistique de vos données téléchargées",
|
||||
"thinking": "Réflexion...",
|
||||
|
@ -51,7 +51,7 @@
|
||||
"shortcut_go_to_shortcuts": "CMDA: Acesse os atalhos",
|
||||
"shortcut_go_to_user_page": "CMDU: Acesse a página do usuário",
|
||||
"shortcut_manage_brains": "CMGC: Gerencie seus cérebros",
|
||||
"shortcut_select_brain": "@: Selecione um cérebro para conversar",
|
||||
"shortcut_select_brain": "@: Selecione um cérebro",
|
||||
"shortcut_select_file": "/: Selecione um arquivo para conversar",
|
||||
"subtitle": "Converse com um modelo de linguagem sobre seus dados enviados",
|
||||
"thinking": "Pensando...",
|
||||
|
@ -51,7 +51,7 @@
|
||||
"shortcut_go_to_shortcuts": "CMDK: Перейти к ярлыкам",
|
||||
"shortcut_go_to_user_page": "CMDU: Перейти на страницу пользователя",
|
||||
"shortcut_manage_brains": "CMDB: Управление вашими мозгами",
|
||||
"shortcut_select_brain": "@: Выберите мозг для общения",
|
||||
"shortcut_select_brain": "@: Выберите мозг",
|
||||
"shortcut_select_file": "/: Выберите файл для общения",
|
||||
"subtitle": "Общайтесь с языковой моделью о ваших загруженных данных",
|
||||
"thinking": "Думаю...",
|
||||
|
@ -52,7 +52,7 @@
|
||||
"shortcut_go_to_shortcuts": "CMDK: 前往快捷方式",
|
||||
"shortcut_go_to_user_page": "CMDU: 进入用户页面",
|
||||
"shortcut_manage_brains": "CMDB: 管理大脑",
|
||||
"shortcut_select_brain": "@: 选择一个大脑进行交流",
|
||||
"shortcut_select_brain": "@: 选择一个大脑",
|
||||
"shortcut_select_file": "/: 选择一个文件进行对话",
|
||||
"subtitle": "与语言模型讨论您上传的数据",
|
||||
"thinking": "思考中…",
|
||||
|
@ -18,7 +18,9 @@ module.exports = {
|
||||
black: "#11243E",
|
||||
primary: "#6142D4",
|
||||
secondary: "#F3ECFF",
|
||||
tertiary: "#F6F4FF",
|
||||
accent: "#13ABBA",
|
||||
highlight: "#FAFAFA",
|
||||
"accent-hover": "#008491",
|
||||
"chat-bg-gray": "#D9D9D9",
|
||||
"msg-gray": "#9B9B9B",
|
||||
|
Loading…
Reference in New Issue
Block a user