mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-01 11:29:28 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/matt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36fa59f9a6 |
@@ -9,8 +9,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { getCurrentWsId } from "@multica/core/platform";
|
||||
import { flattenIssueBuckets, issueKeys } from "@multica/core/issues/queries";
|
||||
@@ -31,13 +29,15 @@ import { StatusIcon } from "../../issues/components/status-icon";
|
||||
import { useT } from "../../i18n";
|
||||
import { Badge } from "@multica/ui/components/ui/badge";
|
||||
import type { IssueStatus } from "@multica/core/types";
|
||||
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
||||
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import {
|
||||
getRecencyMap,
|
||||
recordMentionUsage,
|
||||
sortUserItemsByRecency,
|
||||
} from "./mention-recency";
|
||||
import { matchesPinyin } from "./pinyin-match";
|
||||
import { createSuggestionPopupRender } from "./suggestion-popup";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -375,10 +375,9 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
SuggestionOptions<MentionItem>,
|
||||
"editor"
|
||||
> {
|
||||
// Renderer/popup instances live in this closure so each ContentEditor owns
|
||||
// its own TipTap suggestion popup lifecycle.
|
||||
let renderer: ReactRenderer<MentionListRef> | null = null;
|
||||
let popup: HTMLDivElement | null = null;
|
||||
// The explicit key is passed into Tiptap Suggestion and reused by the
|
||||
// shared popup controller when it dispatches exitSuggestion(view, pluginKey).
|
||||
const pluginKey = new PluginKey("mentionSuggestion");
|
||||
|
||||
function buildSyncItems(query: string): MentionItem[] {
|
||||
// Read workspace id imperatively because this runs in TipTap factory scope
|
||||
@@ -454,78 +453,21 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
}
|
||||
|
||||
return {
|
||||
pluginKey,
|
||||
items: ({ query }) => {
|
||||
const syncItems = buildSyncItems(query);
|
||||
return syncItems;
|
||||
},
|
||||
|
||||
render: () => {
|
||||
return {
|
||||
onStart: (props: SuggestionProps<MentionItem>) => {
|
||||
renderer = new ReactRenderer(MentionList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
query: props.query,
|
||||
command: props.command,
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
popup = document.createElement("div");
|
||||
popup.style.position = "fixed";
|
||||
popup.style.zIndex = "50";
|
||||
popup.appendChild(renderer.element);
|
||||
document.body.appendChild(popup);
|
||||
|
||||
updatePosition(popup, props.clientRect);
|
||||
},
|
||||
|
||||
onUpdate: (props: SuggestionProps<MentionItem>) => {
|
||||
renderer?.updateProps({
|
||||
items: props.items,
|
||||
query: props.query,
|
||||
command: props.command,
|
||||
});
|
||||
if (popup) updatePosition(popup, props.clientRect);
|
||||
},
|
||||
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
cleanup();
|
||||
return true;
|
||||
}
|
||||
return renderer?.ref?.onKeyDown(props) ?? false;
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
function updatePosition(
|
||||
el: HTMLDivElement,
|
||||
clientRect: (() => DOMRect | null) | null | undefined,
|
||||
) {
|
||||
if (!clientRect) return;
|
||||
const virtualEl = {
|
||||
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
|
||||
};
|
||||
computePosition(virtualEl, el, {
|
||||
placement: "bottom-start",
|
||||
strategy: "fixed",
|
||||
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
popup?.remove();
|
||||
popup = null;
|
||||
}
|
||||
},
|
||||
render: createSuggestionPopupRender<MentionItem, MentionItem, MentionListRef, MentionListProps>({
|
||||
pluginKey,
|
||||
component: MentionList,
|
||||
getProps: (props) => ({
|
||||
items: props.items,
|
||||
query: props.query,
|
||||
command: props.command,
|
||||
}),
|
||||
onKeyDown: (ref, props) => ref?.onKeyDown(props) ?? false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,10 +8,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
||||
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { getCurrentWsId } from "@multica/core/platform";
|
||||
@@ -20,6 +19,7 @@ import { isImeComposing } from "@multica/core/utils";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import type { Agent, MemberWithUser } from "@multica/core/types";
|
||||
import { useT } from "../../i18n";
|
||||
import { createSuggestionPopupRender } from "./suggestion-popup";
|
||||
|
||||
const MAX_ITEMS = 20;
|
||||
|
||||
@@ -162,11 +162,11 @@ export function createSlashCommandSuggestion(qc: QueryClient): Omit<
|
||||
SuggestionOptions<SlashCommandItem>,
|
||||
"editor"
|
||||
> {
|
||||
let renderer: ReactRenderer<SlashCommandListRef> | null = null;
|
||||
let popup: HTMLDivElement | null = null;
|
||||
const pluginKey = new PluginKey("slashCommandSuggestion");
|
||||
|
||||
return {
|
||||
char: "/",
|
||||
pluginKey,
|
||||
items: ({ query }) => buildItems(qc, query),
|
||||
command: ({ editor, range, props }) => {
|
||||
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
|
||||
@@ -193,70 +193,15 @@ export function createSlashCommandSuggestion(qc: QueryClient): Omit<
|
||||
|
||||
window.getSelection()?.collapseToEnd();
|
||||
},
|
||||
render: () => {
|
||||
return {
|
||||
onStart: (props: SuggestionProps<SlashCommandItem>) => {
|
||||
renderer = new ReactRenderer(SlashCommandList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
query: props.query,
|
||||
command: props.command,
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
popup = document.createElement("div");
|
||||
popup.style.position = "fixed";
|
||||
popup.style.zIndex = "50";
|
||||
popup.appendChild(renderer.element);
|
||||
document.body.appendChild(popup);
|
||||
|
||||
updatePosition(popup, props.clientRect);
|
||||
},
|
||||
onUpdate: (props: SuggestionProps<SlashCommandItem>) => {
|
||||
renderer?.updateProps({
|
||||
items: props.items,
|
||||
query: props.query,
|
||||
command: props.command,
|
||||
});
|
||||
if (popup) updatePosition(popup, props.clientRect);
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
cleanup();
|
||||
return true;
|
||||
}
|
||||
return renderer?.ref?.onKeyDown(props) ?? false;
|
||||
},
|
||||
onExit: () => {
|
||||
cleanup();
|
||||
},
|
||||
};
|
||||
},
|
||||
render: createSuggestionPopupRender<SlashCommandItem, SlashCommandItem, SlashCommandListRef, SlashCommandListProps>({
|
||||
pluginKey,
|
||||
component: SlashCommandList,
|
||||
getProps: (props) => ({
|
||||
items: props.items,
|
||||
query: props.query,
|
||||
command: props.command,
|
||||
}),
|
||||
onKeyDown: (ref, props) => ref?.onKeyDown(props) ?? false,
|
||||
}),
|
||||
};
|
||||
|
||||
function updatePosition(
|
||||
el: HTMLDivElement,
|
||||
clientRect: (() => DOMRect | null) | null | undefined,
|
||||
) {
|
||||
if (!clientRect) return;
|
||||
const virtualEl = {
|
||||
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
|
||||
};
|
||||
computePosition(virtualEl, el, {
|
||||
placement: "bottom-start",
|
||||
strategy: "fixed",
|
||||
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
popup?.remove();
|
||||
popup = null;
|
||||
}
|
||||
}
|
||||
|
||||
216
packages/views/editor/extensions/suggestion-popup.test.tsx
Normal file
216
packages/views/editor/extensions/suggestion-popup.test.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Extension, Editor } from "@tiptap/core";
|
||||
import { EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Suggestion, type SuggestionProps } from "@tiptap/suggestion";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createSuggestionPopupRender } from "./suggestion-popup";
|
||||
|
||||
interface TestItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TestListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
interface TestListProps {
|
||||
items: TestItem[];
|
||||
command: (item: TestItem) => void;
|
||||
}
|
||||
|
||||
const TestSuggestionList = forwardRef<TestListRef, TestListProps>(
|
||||
function TestSuggestionList({ items, command }, ref) {
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: () => false,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div data-testid="suggestion-popup">
|
||||
{items.map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => command(item)}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
let editor: Editor | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
const rect = () => new DOMRect(0, 0, 0, 0);
|
||||
const rectList = () => ({ length: 0, item: () => null, [Symbol.iterator]: function* () {} }) as DOMRectList;
|
||||
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: rect,
|
||||
});
|
||||
Object.defineProperty(Range.prototype, "getClientRects", {
|
||||
configurable: true,
|
||||
value: rectList,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "getClientRects", {
|
||||
configurable: true,
|
||||
value: rectList,
|
||||
});
|
||||
Object.defineProperty(Text.prototype, "getClientRects", {
|
||||
configurable: true,
|
||||
value: rectList,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
editor = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function makeEditor(char: "@" | "/") {
|
||||
const pluginKey = new PluginKey(`test-${char}-suggestion`);
|
||||
const item = char === "@"
|
||||
? { id: "u1", label: "Alice" }
|
||||
: { id: "s1", label: "ship" };
|
||||
|
||||
const TestSuggestionExtension = Extension.create({
|
||||
name: `testSuggestion${char}`,
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion<TestItem, TestItem>({
|
||||
editor: this.editor,
|
||||
char,
|
||||
pluginKey,
|
||||
items: () => [item],
|
||||
command: ({ editor: ed, range, props }) => {
|
||||
ed.commands.insertContentAt(range, `${char}${props.label}`);
|
||||
},
|
||||
render: createSuggestionPopupRender<TestItem, TestItem, TestListRef, TestListProps>({
|
||||
pluginKey,
|
||||
component: TestSuggestionList,
|
||||
getProps: (props: SuggestionProps<TestItem, TestItem>) => ({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
}),
|
||||
onKeyDown: (ref, props) => ref?.onKeyDown(props) ?? false,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit, TestSuggestionExtension],
|
||||
content: "",
|
||||
});
|
||||
render(<EditorContent editor={editor} />);
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function triggerSuggestion(ed: Editor, text: string) {
|
||||
await act(async () => {
|
||||
ed.commands.focus("end");
|
||||
ed.commands.insertContent(text);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("suggestion-popup")).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPopupClosed() {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("suggestion-popup")).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
describe("createSuggestionPopupRender", () => {
|
||||
it.each(["@", "/"] as const)(
|
||||
"closes the %s popup through a real pluginKey on outside pointerdown",
|
||||
async (char) => {
|
||||
const ed = makeEditor(char);
|
||||
await triggerSuggestion(ed, `${char}a`);
|
||||
|
||||
const outside = document.createElement("button");
|
||||
document.body.appendChild(outside);
|
||||
act(() => {
|
||||
fireEvent.pointerDown(outside);
|
||||
});
|
||||
|
||||
await expectPopupClosed();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["@", "/"] as const)(
|
||||
"closes the %s popup through a real pluginKey on outside focusin",
|
||||
async (char) => {
|
||||
const ed = makeEditor(char);
|
||||
await triggerSuggestion(ed, `${char}a`);
|
||||
|
||||
const outside = document.createElement("input");
|
||||
document.body.appendChild(outside);
|
||||
act(() => {
|
||||
fireEvent.focusIn(outside);
|
||||
});
|
||||
|
||||
await expectPopupClosed();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["@", "/"] as const)(
|
||||
"closes the %s popup through a real pluginKey on window blur",
|
||||
async (char) => {
|
||||
const ed = makeEditor(char);
|
||||
await triggerSuggestion(ed, `${char}a`);
|
||||
|
||||
act(() => {
|
||||
fireEvent.blur(window);
|
||||
});
|
||||
|
||||
await expectPopupClosed();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["@", "/"] as const)(
|
||||
"can reopen the %s popup after an explicit exit",
|
||||
async (char) => {
|
||||
const ed = makeEditor(char);
|
||||
await triggerSuggestion(ed, `${char}a`);
|
||||
|
||||
const outside = document.createElement("button");
|
||||
document.body.appendChild(outside);
|
||||
fireEvent.pointerDown(outside);
|
||||
await expectPopupClosed();
|
||||
|
||||
await act(async () => {
|
||||
ed.commands.insertContent(` ${char}b`);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("suggestion-popup")).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["@", "/"] as const)(
|
||||
"keeps the %s popup open long enough for candidate row clicks to insert",
|
||||
async (char) => {
|
||||
const ed = makeEditor(char);
|
||||
await triggerSuggestion(ed, `${char}a`);
|
||||
|
||||
const label = char === "@" ? "Alice" : "ship";
|
||||
const row = screen.getByRole("button", { name: label });
|
||||
act(() => {
|
||||
fireEvent.pointerDown(row);
|
||||
fireEvent.click(row);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ed.getText()).toContain(`${char}${label}`);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
146
packages/views/editor/extensions/suggestion-popup.tsx
Normal file
146
packages/views/editor/extensions/suggestion-popup.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { exitSuggestion, type SuggestionKeyDownProps, type SuggestionProps } from "@tiptap/suggestion";
|
||||
import type { PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
interface SuggestionPopupRenderOptions<
|
||||
TItem,
|
||||
TSelected = TItem,
|
||||
TRef = unknown,
|
||||
TComponentProps extends object = object,
|
||||
> {
|
||||
pluginKey: PluginKey;
|
||||
component: ComponentType<TComponentProps>;
|
||||
getProps: (props: SuggestionProps<TItem, TSelected>) => TComponentProps;
|
||||
onKeyDown?: (
|
||||
ref: TRef | null | undefined,
|
||||
props: SuggestionKeyDownProps,
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
export function createSuggestionPopupRender<
|
||||
TItem,
|
||||
TSelected = TItem,
|
||||
TRef = unknown,
|
||||
TComponentProps extends object = object,
|
||||
>({
|
||||
pluginKey,
|
||||
component,
|
||||
getProps,
|
||||
onKeyDown,
|
||||
}: SuggestionPopupRenderOptions<TItem, TSelected, TRef, TComponentProps>) {
|
||||
return () => {
|
||||
let renderer: ReactRenderer<TRef> | null = null;
|
||||
let popup: HTMLDivElement | null = null;
|
||||
let removeOutsideListeners: (() => void) | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
removeOutsideListeners?.();
|
||||
removeOutsideListeners = null;
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
popup?.remove();
|
||||
popup = null;
|
||||
};
|
||||
|
||||
const requestExit = (props: SuggestionProps<TItem, TSelected>) => {
|
||||
exitSuggestion(props.editor.view, pluginKey);
|
||||
};
|
||||
|
||||
const isInsideSuggestionSurface = (
|
||||
target: EventTarget | null,
|
||||
props: SuggestionProps<TItem, TSelected>,
|
||||
) => {
|
||||
if (!(target instanceof Node)) return false;
|
||||
return props.editor.view.dom.contains(target) || !!popup?.contains(target);
|
||||
};
|
||||
|
||||
const installOutsideListeners = (props: SuggestionProps<TItem, TSelected>) => {
|
||||
removeOutsideListeners?.();
|
||||
const doc = props.editor.view.dom.ownerDocument;
|
||||
const win = doc.defaultView ?? window;
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (isInsideSuggestionSurface(event.target, props)) return;
|
||||
requestExit(props);
|
||||
};
|
||||
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
if (isInsideSuggestionSurface(event.target, props)) return;
|
||||
requestExit(props);
|
||||
};
|
||||
|
||||
const onWindowBlur = () => {
|
||||
requestExit(props);
|
||||
};
|
||||
|
||||
doc.addEventListener("pointerdown", onPointerDown, true);
|
||||
doc.addEventListener("focusin", onFocusIn, true);
|
||||
win.addEventListener("blur", onWindowBlur);
|
||||
|
||||
removeOutsideListeners = () => {
|
||||
doc.removeEventListener("pointerdown", onPointerDown, true);
|
||||
doc.removeEventListener("focusin", onFocusIn, true);
|
||||
win.removeEventListener("blur", onWindowBlur);
|
||||
};
|
||||
};
|
||||
|
||||
const updatePosition = (
|
||||
el: HTMLDivElement,
|
||||
clientRect: (() => DOMRect | null) | null | undefined,
|
||||
) => {
|
||||
if (!clientRect) return;
|
||||
const virtualEl = {
|
||||
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
|
||||
};
|
||||
computePosition(virtualEl, el, {
|
||||
placement: "bottom-start",
|
||||
strategy: "fixed",
|
||||
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
if (popup !== el) return;
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps<TItem, TSelected>) => {
|
||||
renderer = new ReactRenderer(component, {
|
||||
props: getProps(props),
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
const doc = props.editor.view.dom.ownerDocument;
|
||||
popup = doc.createElement("div");
|
||||
popup.style.position = "fixed";
|
||||
popup.style.zIndex = "50";
|
||||
popup.appendChild(renderer.element);
|
||||
doc.body.appendChild(popup);
|
||||
|
||||
installOutsideListeners(props);
|
||||
updatePosition(popup, props.clientRect);
|
||||
},
|
||||
|
||||
onUpdate: (props: SuggestionProps<TItem, TSelected>) => {
|
||||
renderer?.updateProps(getProps(props));
|
||||
if (popup) updatePosition(popup, props.clientRect);
|
||||
},
|
||||
|
||||
onKeyDown: (props: SuggestionKeyDownProps) => {
|
||||
if (props.event.key === "Escape") {
|
||||
cleanup();
|
||||
return true;
|
||||
}
|
||||
return onKeyDown?.(renderer?.ref, props) ?? false;
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
cleanup();
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user