feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
|
||||
import { TemplateParser } from '../../shared/utils/template-parser';
|
||||
const templateParser = new TemplateParser({ mark: 'InputSlot' });
|
||||
|
||||
export const insertInputSlot = (
|
||||
editor: EditorAPI,
|
||||
options?: {
|
||||
mode?: 'input' | 'configurable';
|
||||
placeholder?: string;
|
||||
},
|
||||
) => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
mode = 'input',
|
||||
placeholder = I18n.t('edit_block_guidance_text_placeholder'),
|
||||
} = options ?? {};
|
||||
const { selection } = editor.$view.state;
|
||||
const selectionRange = editor.$view.state.selection.main;
|
||||
const content = editor.$view.state.sliceDoc(
|
||||
selectionRange.from,
|
||||
selectionRange.to,
|
||||
);
|
||||
const extractedContent = templateParser.extractTemplateContent(content);
|
||||
const { open, template, textContent } = templateParser.generateTemplateJson({
|
||||
content: extractedContent,
|
||||
data: {
|
||||
placeholder,
|
||||
mode,
|
||||
},
|
||||
});
|
||||
const from = selectionRange.from + open.length;
|
||||
const to = from + textContent.length;
|
||||
editor.$view.dispatch({
|
||||
changes: {
|
||||
from: selectionRange.from,
|
||||
to: selectionRange.to,
|
||||
insert: template,
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
editor.$view.dispatch({
|
||||
selection: selection.replaceRange(EditorSelection.range(from, to)),
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useEditor } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
import { TemplateParser } from '../../shared/utils/template-parser';
|
||||
|
||||
const templateParser = new TemplateParser({ mark: 'InputSlot' });
|
||||
export const useCursorInInputSlot = () => {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const [inInputSlot, setInInputSlot] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const handleViewUpdate = (update: ViewUpdate) => {
|
||||
if (update.selectionSet) {
|
||||
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
|
||||
update.state.selection.main,
|
||||
update.state,
|
||||
);
|
||||
if (markRangeInfo) {
|
||||
setInInputSlot(true);
|
||||
return;
|
||||
}
|
||||
setInInputSlot(false);
|
||||
}
|
||||
};
|
||||
editor.$on('viewUpdate', handleViewUpdate);
|
||||
return () => {
|
||||
editor.$off('viewUpdate', handleViewUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return inInputSlot;
|
||||
};
|
||||
|
||||
export const useSelectionInInputSlot = () => {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const [inInputSlot, setInInputSlot] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const handleViewUpdate = (update: ViewUpdate) => {
|
||||
if (!update.state.selection.main.empty) {
|
||||
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
|
||||
update.state.selection.main,
|
||||
update.state,
|
||||
);
|
||||
if (markRangeInfo) {
|
||||
setInInputSlot(true);
|
||||
return;
|
||||
}
|
||||
setInInputSlot(false);
|
||||
}
|
||||
};
|
||||
editor.$on('viewUpdate', handleViewUpdate);
|
||||
return () => {
|
||||
editor.$off('viewUpdate', handleViewUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
return inInputSlot;
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useEditor } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
export const useSelectionInJinjaRaw = () => {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const [inJinjaRaw, setInJinjaRaw] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkInJinjaRaw = () => {
|
||||
const selection = editor.getSelection();
|
||||
if (!selection) {
|
||||
setInJinjaRaw(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { state } = editor.$view;
|
||||
const tree = syntaxTree(state);
|
||||
const cursor = tree.cursor();
|
||||
|
||||
let isInRaw = false;
|
||||
do {
|
||||
if (cursor.name === 'RawText') {
|
||||
const isSelectionWithinNode =
|
||||
cursor.from <= selection.from && cursor.to >= selection.to;
|
||||
if (isSelectionWithinNode) {
|
||||
isInRaw = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} while (cursor.next());
|
||||
|
||||
setInJinjaRaw(isInRaw);
|
||||
};
|
||||
|
||||
editor.$on('viewUpdate', checkInJinjaRaw);
|
||||
|
||||
// 初始检查
|
||||
checkInJinjaRaw();
|
||||
|
||||
return () => {
|
||||
editor.$off('viewUpdate', checkInJinjaRaw);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return inJinjaRaw;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { InputSlotWidget } from './input-slot-widget';
|
||||
export { insertInputSlot } from './action/insert-input-slot-action';
|
||||
export {
|
||||
useCursorInInputSlot,
|
||||
useSelectionInInputSlot,
|
||||
} from './hooks/use-in-input-slot';
|
||||
export { useSelectionInJinjaRaw } from './hooks/use-in-jinja-raw';
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { PositionMirror } from '@coze-editor/editor/react';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Popover, Input, type PopoverProps } from '@coze-arch/coze-design';
|
||||
|
||||
interface InputConfigPopoverProps {
|
||||
visible: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
positon: number;
|
||||
direction?: PopoverProps['position'];
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onPlaceholderChange?: (placeholder: string) => void;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
export const InputConfigPopover = (props: InputConfigPopoverProps) => {
|
||||
const [reposKey, setReposKey] = useState('');
|
||||
const { direction, placeholder, value } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
rePosKey={reposKey}
|
||||
visible={props.visible}
|
||||
trigger="custom"
|
||||
position={direction}
|
||||
autoAdjustOverflow
|
||||
content={
|
||||
<div className="flex flex-col gap-2 pt-3 pb-4 px-4 w-[320px]">
|
||||
<div>
|
||||
<div>{I18n.t('edit_block_guidance_text_when_empty')}</div>
|
||||
<Input
|
||||
value={placeholder}
|
||||
placeholder={I18n.t('edit_block_guidance_text_placeholder')}
|
||||
onChange={v => props.onPlaceholderChange?.(v)}
|
||||
onBlur={() => {
|
||||
if (!placeholder) {
|
||||
props.onPlaceholderChange?.(
|
||||
I18n.t('edit_block_guidance_text_placeholder'),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div>{I18n.t('edit_block_prefilled_text')}</div>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={I18n.t('edit_block_default_guidance_text')}
|
||||
onChange={v => props.onValueChange?.(v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PositionMirror
|
||||
position={props.positon}
|
||||
onChange={() => setReposKey(String(Math.random()))}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useEditor } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
import { InputConfigPopover } from '../input-config-popover';
|
||||
import { type TemplateParser } from '../../shared/utils/template-parser';
|
||||
|
||||
export const ConfigModeWidgetPopover = (props: {
|
||||
direction: 'bottomLeft' | 'topLeft' | 'bottomRight' | 'topRight';
|
||||
templateParser: TemplateParser;
|
||||
}) => {
|
||||
const { direction, templateParser } = props;
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const [placeholder, setPlaceholder] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [configPopoverVisible, setConfigPopoverVisible] = useState(false);
|
||||
const [popoverPosition, setPopoverPosition] = useState(-1);
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const handleViewUpdate = (e: ViewUpdate) => {
|
||||
if (e.docChanged) {
|
||||
// 判断当前光标是否在 slot 节点内
|
||||
const { state } = e;
|
||||
const range = templateParser.getCursorInMarkNodeRange(state);
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
const content = state.sliceDoc(range.open.to, range.close.from);
|
||||
if (content === value) {
|
||||
return;
|
||||
}
|
||||
setValue(content);
|
||||
}
|
||||
if (e.selectionSet) {
|
||||
const { state } = e;
|
||||
const range = templateParser.getCursorInMarkNodeRange(state);
|
||||
if (!range) {
|
||||
setPopoverPosition(-1);
|
||||
setConfigPopoverVisible(false);
|
||||
return;
|
||||
}
|
||||
const content = templateParser.getCursorTemplateContent(editor);
|
||||
const { placeholder: configPlaceholder } =
|
||||
templateParser.getCursorTemplateData(state) ?? {};
|
||||
setPlaceholder(configPlaceholder);
|
||||
setValue(content ?? '');
|
||||
setPopoverPosition(range.open.from);
|
||||
setConfigPopoverVisible(true);
|
||||
}
|
||||
};
|
||||
editor.$on('viewUpdate', handleViewUpdate);
|
||||
|
||||
return () => {
|
||||
editor.$off('viewUpdate', handleViewUpdate);
|
||||
};
|
||||
}, [editor, value]);
|
||||
|
||||
const handlePlaceholderChange = (configPlaceholder: string) => {
|
||||
if (!editor || !configPopoverVisible) {
|
||||
return;
|
||||
}
|
||||
setPlaceholder(configPlaceholder);
|
||||
templateParser.updateCursorTemplateData(editor, {
|
||||
placeholder: configPlaceholder,
|
||||
});
|
||||
};
|
||||
const handleValueChange = (configValue: string) => {
|
||||
if (!editor || !configPopoverVisible) {
|
||||
return;
|
||||
}
|
||||
setValue(configValue);
|
||||
templateParser.updateCursorTemplateContent(editor, configValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputConfigPopover
|
||||
visible={configPopoverVisible}
|
||||
positon={popoverPosition}
|
||||
direction={direction}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onPlaceholderChange={handlePlaceholderChange}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
|
||||
.cm-content {
|
||||
.slot-content {
|
||||
color: #4E40E5;
|
||||
word-break: break-all;
|
||||
background-color: rgba(186, 192, 255, 20%);
|
||||
|
||||
/* padding: 2px 0; */
|
||||
}
|
||||
|
||||
.slot-side-left {
|
||||
/* padding: 2px 0 2px 5px; */
|
||||
padding-left: 5px;
|
||||
background-color: rgba(186, 192, 255, 20%);
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.slot-side-right {
|
||||
margin-right: 4px;
|
||||
|
||||
/* padding: 2px 5px 2px 0; */
|
||||
padding-right: 5px;
|
||||
background-color: rgba(186, 192, 255, 20%);
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.slot-placeholder {
|
||||
color: rgba(148, 152, 247, 70%);
|
||||
word-break: break-all;
|
||||
background-color: rgba(186, 192, 255, 20%);
|
||||
|
||||
/* padding: 2px 0; */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigModeWidgetPopover } from './config-mode-popover';
|
||||
|
||||
import './index.css';
|
||||
import { useEffect, useLayoutEffect } from 'react';
|
||||
|
||||
import { useEditor, useInjector } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import {
|
||||
astDecorator,
|
||||
SpanWidget,
|
||||
autoSelectRanges,
|
||||
selectionEnlarger,
|
||||
deletionEnlarger,
|
||||
} from '@coze-editor/editor';
|
||||
import { type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
import {
|
||||
type MarkRangeInfo,
|
||||
TemplateParser,
|
||||
} from '../../shared/utils/template-parser';
|
||||
import { useReadonly } from '../../shared/hooks/use-editor-readonly';
|
||||
interface InputSlotWidgetProps {
|
||||
mode?: 'input' | 'configurable';
|
||||
onSelectionInInputSlot?: (selection: MarkRangeInfo | undefined) => void;
|
||||
}
|
||||
|
||||
const templateParser = new TemplateParser({ mark: 'InputSlot' });
|
||||
|
||||
export const InputSlotWidget = (props: InputSlotWidgetProps) => {
|
||||
const { mode, onSelectionInInputSlot } = props;
|
||||
const injector = useInjector();
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const readonly = useReadonly();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const { markInfoField } = templateParser;
|
||||
|
||||
return injector.inject([
|
||||
astDecorator.whole.of((cursor, state) => {
|
||||
if (templateParser.isOpenNode(cursor.node, state)) {
|
||||
const open = cursor.node;
|
||||
const close = templateParser.findCloseNode(open, state);
|
||||
|
||||
if (close) {
|
||||
const openTemplate = state.sliceDoc(open.from, open.to);
|
||||
const data = templateParser.getData(openTemplate);
|
||||
const from = open.to;
|
||||
const to = close.from;
|
||||
|
||||
if (from === to) {
|
||||
return [
|
||||
{
|
||||
type: 'replace',
|
||||
widget: new SpanWidget({
|
||||
className: 'slot-side-left',
|
||||
}),
|
||||
atomicRange: true,
|
||||
from: open.from,
|
||||
to: open.to,
|
||||
},
|
||||
{
|
||||
type: 'widget',
|
||||
widget: new SpanWidget({
|
||||
text: data?.placeholder || '',
|
||||
className: 'slot-placeholder',
|
||||
}),
|
||||
from,
|
||||
atomicRange: true,
|
||||
side: 1,
|
||||
},
|
||||
{
|
||||
type: 'replace',
|
||||
widget: new SpanWidget({
|
||||
className: 'slot-side-right',
|
||||
}),
|
||||
atomicRange: true,
|
||||
from: close.from,
|
||||
to: close.to,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: 'replace',
|
||||
widget: new SpanWidget({
|
||||
className: 'slot-side-left',
|
||||
}),
|
||||
atomicRange: true,
|
||||
from: open.from,
|
||||
to: open.to,
|
||||
},
|
||||
{
|
||||
type: 'className',
|
||||
className: 'slot-content',
|
||||
from,
|
||||
to,
|
||||
},
|
||||
{
|
||||
type: 'replace',
|
||||
widget: new SpanWidget({ className: 'slot-side-right' }),
|
||||
atomicRange: true,
|
||||
from: close.from,
|
||||
to: close.to,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
markInfoField,
|
||||
|
||||
autoSelectRanges.of(state => state.field(markInfoField).contents),
|
||||
|
||||
selectionEnlarger.of(state => state.field(markInfoField).specs),
|
||||
|
||||
deletionEnlarger.of(state => state.field(markInfoField).specs),
|
||||
]);
|
||||
}, [injector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const handleViewUpdate = (update: ViewUpdate) => {
|
||||
if (!update.state.selection.main.empty) {
|
||||
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
|
||||
update.state.selection.main,
|
||||
update.state,
|
||||
);
|
||||
if (markRangeInfo) {
|
||||
onSelectionInInputSlot?.(markRangeInfo);
|
||||
return;
|
||||
}
|
||||
onSelectionInInputSlot?.(undefined);
|
||||
}
|
||||
};
|
||||
editor.$on('viewUpdate', handleViewUpdate);
|
||||
return () => {
|
||||
editor.$off('viewUpdate', handleViewUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (mode === 'configurable' && !readonly) {
|
||||
return (
|
||||
<ConfigModeWidgetPopover
|
||||
direction="bottomLeft"
|
||||
templateParser={templateParser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user