mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +00:00
feat(webui): preview dragged session mentions
This commit is contained in:
parent
9cf6cf0639
commit
f44a766f98
@ -40,7 +40,7 @@ import {
|
|||||||
visibleSessionsForGroup,
|
visibleSessionsForGroup,
|
||||||
type ChatGroupLabels,
|
type ChatGroupLabels,
|
||||||
} from "@/lib/chat-groups";
|
} from "@/lib/chat-groups";
|
||||||
import { writeDraggedSession } from "@/lib/session-drag";
|
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||||
|
|
||||||
@ -357,6 +357,7 @@ export const ChatList = memo(function ChatList({
|
|||||||
writeDraggedSession(event.dataTransfer, s.key);
|
writeDraggedSession(event.dataTransfer, s.key);
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => {
|
onDragEnd={() => {
|
||||||
|
clearDraggedSession();
|
||||||
setDraggedSessionKey(null);
|
setDraggedSessionKey(null);
|
||||||
setSessionDropTarget(null);
|
setSessionDropTarget(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -106,6 +106,7 @@ import {
|
|||||||
slashCommandLifecycle,
|
slashCommandLifecycle,
|
||||||
} from "@/lib/slash-command";
|
} from "@/lib/slash-command";
|
||||||
import {
|
import {
|
||||||
|
clearDraggedSession,
|
||||||
hasDraggedSession,
|
hasDraggedSession,
|
||||||
readDraggedSession,
|
readDraggedSession,
|
||||||
} from "@/lib/session-drag";
|
} from "@/lib/session-drag";
|
||||||
@ -321,6 +322,35 @@ type MentionCandidate = {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
interface MentionInsertion {
|
||||||
|
value: string;
|
||||||
|
cursor: number;
|
||||||
|
tokenStart: number;
|
||||||
|
tokenEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionInsertion(
|
||||||
|
value: string,
|
||||||
|
name: string,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
): MentionInsertion {
|
||||||
|
const from = Math.min(Math.max(start, 0), value.length);
|
||||||
|
const to = Math.min(Math.max(end, from), value.length);
|
||||||
|
const prefix = value.slice(0, from);
|
||||||
|
const suffix = value.slice(to);
|
||||||
|
const leadingSpace = prefix && !/\s$/.test(prefix) ? " " : "";
|
||||||
|
const trailingSpace = /^\s/.test(suffix) ? "" : " ";
|
||||||
|
const tokenStart = prefix.length + leadingSpace.length;
|
||||||
|
const tokenEnd = tokenStart + name.length + 1;
|
||||||
|
return {
|
||||||
|
value: `${prefix}${leadingSpace}@${name}${trailingSpace}${suffix}`,
|
||||||
|
cursor: tokenEnd + trailingSpace.length,
|
||||||
|
tokenStart,
|
||||||
|
tokenEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sessionMentionBase(session: ChatSummary): string {
|
function sessionMentionBase(session: ChatSummary): string {
|
||||||
const label = session.title?.trim() || session.preview.trim() || "session";
|
const label = session.title?.trim() || session.preview.trim() || "session";
|
||||||
const slug = label
|
const slug = label
|
||||||
@ -940,6 +970,11 @@ export function ThreadComposer({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [selectedSessionMentions, setSelectedSessionMentions] = useState<SessionMention[]>([]);
|
const [selectedSessionMentions, setSelectedSessionMentions] = useState<SessionMention[]>([]);
|
||||||
|
const [sessionDragPreview, setSessionDragPreview] = useState<{
|
||||||
|
mention: SessionMention;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
} | null>(null);
|
||||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||||
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
||||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||||
@ -1267,6 +1302,22 @@ export function ThreadComposer({
|
|||||||
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets, selectedSessionMentions),
|
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets, selectedSessionMentions),
|
||||||
[cliApps, mcpPresets, selectedSessionMentions, value],
|
[cliApps, mcpPresets, selectedSessionMentions, value],
|
||||||
);
|
);
|
||||||
|
const sessionDragInsertion = sessionDragPreview
|
||||||
|
? mentionInsertion(
|
||||||
|
value,
|
||||||
|
sessionDragPreview.mention.name,
|
||||||
|
sessionDragPreview.start,
|
||||||
|
sessionDragPreview.end,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const displayMentionSegments = sessionDragInsertion && sessionDragPreview
|
||||||
|
? splitCapabilityMentionSegments(
|
||||||
|
sessionDragInsertion.value,
|
||||||
|
cliApps,
|
||||||
|
mcpPresets,
|
||||||
|
[...selectedSessionMentions, sessionDragPreview.mention],
|
||||||
|
)
|
||||||
|
: mentionSegments;
|
||||||
const activeSessionMentions = useMemo(() => {
|
const activeSessionMentions = useMemo(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return mentionSegments.flatMap((segment) => {
|
return mentionSegments.flatMap((segment) => {
|
||||||
@ -1355,7 +1406,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const showCliAppMenu = filteredMentionCandidates.length > 0;
|
const showCliAppMenu = filteredMentionCandidates.length > 0;
|
||||||
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
||||||
const hasMentionDecorations = mentionSegments.some(
|
const hasMentionDecorations = displayMentionSegments.some(
|
||||||
(segment) => segment.kind !== "text",
|
(segment) => segment.kind !== "text",
|
||||||
);
|
);
|
||||||
const activeCliMentionApps = useMemo(() => {
|
const activeCliMentionApps = useMemo(() => {
|
||||||
@ -1626,15 +1677,9 @@ export function ThreadComposer({
|
|||||||
candidate.mention,
|
candidate.mention,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
const prefix = value.slice(0, start);
|
const insertion = mentionInsertion(value, candidate.name, start, end);
|
||||||
const suffix = value.slice(end);
|
setValue(insertion.value);
|
||||||
const leadingSpace = prefix && !/\s$/.test(prefix) ? " " : "";
|
setCursorPosition(insertion.cursor);
|
||||||
const trailingSpace = /^\s/.test(suffix) ? "" : " ";
|
|
||||||
const mention = `${leadingSpace}@${candidate.name}${trailingSpace}`;
|
|
||||||
const next = `${prefix}${mention}${suffix}`;
|
|
||||||
const nextCursor = prefix.length + mention.length;
|
|
||||||
setValue(next);
|
|
||||||
setCursorPosition(nextCursor);
|
|
||||||
setCliAppMenuDismissed(true);
|
setCliAppMenuDismissed(true);
|
||||||
setSlashMenuDismissed(false);
|
setSlashMenuDismissed(false);
|
||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
@ -1643,7 +1688,7 @@ export function ThreadComposer({
|
|||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.focus();
|
el.focus();
|
||||||
el.setSelectionRange(nextCursor, nextCursor);
|
el.setSelectionRange(insertion.cursor, insertion.cursor);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[activeSessionMentions, resizeTextarea, value],
|
[activeSessionMentions, resizeTextarea, value],
|
||||||
@ -1660,13 +1705,16 @@ export function ThreadComposer({
|
|||||||
const handleSessionDrop = useCallback((event: React.DragEvent) => {
|
const handleSessionDrop = useCallback((event: React.DragEvent) => {
|
||||||
if (!hasDraggedSession(event.dataTransfer)) return false;
|
if (!hasDraggedSession(event.dataTransfer)) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
clearDraggedSession();
|
||||||
|
const preview = sessionDragPreview;
|
||||||
|
setSessionDragPreview(null);
|
||||||
if (disabled) return true;
|
if (disabled) return true;
|
||||||
const sessionKey = readDraggedSession(event.dataTransfer);
|
const sessionKey = readDraggedSession(event.dataTransfer);
|
||||||
const mention = availableSessionMentions.find(
|
const mention = availableSessionMentions.find(
|
||||||
(candidate) => candidate.session_key === sessionKey,
|
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
|
||||||
);
|
);
|
||||||
if (!mention) return true;
|
if (!mention) return true;
|
||||||
const caret = textareaRef.current?.selectionStart ?? value.length;
|
const caret = preview?.start ?? textareaRef.current?.selectionStart ?? value.length;
|
||||||
insertMentionCandidate(
|
insertMentionCandidate(
|
||||||
{
|
{
|
||||||
kind: "session",
|
kind: "session",
|
||||||
@ -1675,10 +1723,51 @@ export function ThreadComposer({
|
|||||||
mention,
|
mention,
|
||||||
},
|
},
|
||||||
caret,
|
caret,
|
||||||
textareaRef.current?.selectionEnd ?? caret,
|
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}, [availableSessionMentions, disabled, insertMentionCandidate, value.length]);
|
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]);
|
||||||
|
|
||||||
|
const previewSessionDrop = useCallback((event: React.DragEvent) => {
|
||||||
|
if (!hasDraggedSession(event.dataTransfer)) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
if (disabled) {
|
||||||
|
setSessionDragPreview(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const sessionKey = readDraggedSession(event.dataTransfer);
|
||||||
|
const mention = availableSessionMentions.find(
|
||||||
|
(candidate) => candidate.session_key === sessionKey,
|
||||||
|
);
|
||||||
|
const alreadySelected = mention && activeSessionMentions.some(
|
||||||
|
(candidate) => candidate.session_key === mention.session_key,
|
||||||
|
);
|
||||||
|
if (!mention || (!alreadySelected && activeSessionMentions.length >= SESSION_MENTIONS_LIMIT)) {
|
||||||
|
setSessionDragPreview(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const start = textareaRef.current?.selectionStart ?? value.length;
|
||||||
|
const end = textareaRef.current?.selectionEnd ?? start;
|
||||||
|
setSessionDragPreview((current) => (
|
||||||
|
current?.mention.session_key === mention.session_key
|
||||||
|
&& current.start === start
|
||||||
|
&& current.end === end
|
||||||
|
? current
|
||||||
|
: { mention, start, end }
|
||||||
|
));
|
||||||
|
return true;
|
||||||
|
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionDragPreview) return;
|
||||||
|
const clearPreview = () => {
|
||||||
|
clearDraggedSession();
|
||||||
|
setSessionDragPreview(null);
|
||||||
|
};
|
||||||
|
document.addEventListener("dragend", clearPreview);
|
||||||
|
return () => document.removeEventListener("dragend", clearPreview);
|
||||||
|
}, [sessionDragPreview]);
|
||||||
|
|
||||||
const clearComposerText = useCallback((restoreFocus = true) => {
|
const clearComposerText = useCallback((restoreFocus = true) => {
|
||||||
setValue("");
|
setValue("");
|
||||||
@ -2108,16 +2197,22 @@ export function ThreadComposer({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
}}
|
}}
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={(event) => {
|
||||||
|
if (!previewSessionDrop(event)) onDragEnter(event);
|
||||||
|
}}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (hasDraggedSession(event.dataTransfer)) {
|
if (!previewSessionDrop(event)) onDragOver(event);
|
||||||
event.preventDefault();
|
}}
|
||||||
event.dataTransfer.dropEffect = "copy";
|
onDragLeave={(event) => {
|
||||||
} else {
|
if (!hasDraggedSession(event.dataTransfer)) {
|
||||||
onDragOver(event);
|
onDragLeave(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextTarget = event.relatedTarget;
|
||||||
|
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
||||||
|
setSessionDragPreview(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (!handleSessionDrop(event)) onDrop(event);
|
if (!handleSessionDrop(event)) onDrop(event);
|
||||||
}}
|
}}
|
||||||
@ -2150,6 +2245,7 @@ export function ThreadComposer({
|
|||||||
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
||||||
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
||||||
disabled && "opacity-60",
|
disabled && "opacity-60",
|
||||||
|
sessionDragPreview && "ring-1 ring-primary/25",
|
||||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||||
goalState?.active &&
|
goalState?.active &&
|
||||||
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
|
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
|
||||||
@ -2234,9 +2330,12 @@ export function ThreadComposer({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
{hasMentionDecorations ? (
|
{hasMentionDecorations ? (
|
||||||
<ComposerCliMentionOverlay
|
<ComposerCliMentionOverlay
|
||||||
segments={mentionSegments}
|
segments={displayMentionSegments}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
className={inputTextClasses}
|
className={inputTextClasses}
|
||||||
|
ghostRange={sessionDragInsertion
|
||||||
|
? { start: sessionDragInsertion.tokenStart, end: sessionDragInsertion.tokenEnd }
|
||||||
|
: null}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<textarea
|
<textarea
|
||||||
@ -2259,7 +2358,7 @@ export function ThreadComposer({
|
|||||||
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={resolvedPlaceholder}
|
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={t("thread.composer.inputAria")}
|
aria-label={t("thread.composer.inputAria")}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -2646,11 +2745,14 @@ function ComposerCliMentionOverlay({
|
|||||||
segments,
|
segments,
|
||||||
isHero,
|
isHero,
|
||||||
className,
|
className,
|
||||||
|
ghostRange,
|
||||||
}: {
|
}: {
|
||||||
segments: CapabilityMentionSegment[];
|
segments: CapabilityMentionSegment[];
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
className: string;
|
className: string;
|
||||||
|
ghostRange?: { start: number; end: number } | null;
|
||||||
}) {
|
}) {
|
||||||
|
let offset = 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@ -2660,16 +2762,24 @@ function ComposerCliMentionOverlay({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{segments.map((segment, index) => {
|
{segments.map((segment, index) => {
|
||||||
|
const start = offset;
|
||||||
|
offset += segment.text.length;
|
||||||
if (segment.kind === "text") {
|
if (segment.kind === "text") {
|
||||||
return <span key={`text-${index}`}>{segment.text}</span>;
|
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||||
}
|
}
|
||||||
|
const isGhost = ghostRange?.start === start && ghostRange.end === offset;
|
||||||
return (
|
return (
|
||||||
<CapabilityMentionToken
|
<span
|
||||||
key={`${segment.kind}-${index}`}
|
key={`${segment.kind}-${index}`}
|
||||||
segment={segment}
|
data-testid={isGhost ? "composer-session-drag-preview" : undefined}
|
||||||
variant="composer"
|
className={cn(isGhost && "opacity-45 transition-opacity duration-100")}
|
||||||
isHero={isHero}
|
>
|
||||||
/>
|
<CapabilityMentionToken
|
||||||
|
segment={segment}
|
||||||
|
variant="composer"
|
||||||
|
isHero={isHero}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,18 +1,25 @@
|
|||||||
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
|
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
|
||||||
|
|
||||||
|
let activeSessionKey: string | null = null;
|
||||||
|
|
||||||
export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
|
export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
|
||||||
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
|
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readDraggedSession(dataTransfer: DataTransfer): string | null {
|
export function readDraggedSession(dataTransfer: DataTransfer): string | null {
|
||||||
const sessionKey = dataTransfer.getData(SESSION_DRAG_TYPE).trim();
|
const sessionKey = dataTransfer.getData(SESSION_DRAG_TYPE).trim();
|
||||||
return sessionKey || null;
|
return sessionKey || activeSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDraggedSession(): void {
|
||||||
|
activeSessionKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeDraggedSession(
|
export function writeDraggedSession(
|
||||||
dataTransfer: DataTransfer,
|
dataTransfer: DataTransfer,
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
): void {
|
): void {
|
||||||
|
activeSessionKey = sessionKey;
|
||||||
dataTransfer.effectAllowed = "copyMove";
|
dataTransfer.effectAllowed = "copyMove";
|
||||||
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
|
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,7 @@ describe("ChatList", () => {
|
|||||||
SESSION_DRAG_TYPE,
|
SESSION_DRAG_TYPE,
|
||||||
"websocket:reference",
|
"websocket:reference",
|
||||||
);
|
);
|
||||||
|
fireEvent.dragEnd(reference, { dataTransfer });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reorders chats around a Codex-style insertion line", () => {
|
it("reorders chats around a Codex-style insertion line", () => {
|
||||||
|
|||||||
@ -1619,9 +1619,21 @@ describe("ThreadComposer", () => {
|
|||||||
|
|
||||||
fireEvent.dragEnter(input, { dataTransfer });
|
fireEvent.dragEnter(input, { dataTransfer });
|
||||||
fireEvent.dragOver(input, { dataTransfer });
|
fireEvent.dragOver(input, { dataTransfer });
|
||||||
|
|
||||||
|
expect(input).toHaveValue("Compare notes");
|
||||||
|
expect(screen.getByTestId("composer-session-drag-preview"))
|
||||||
|
.toHaveTextContent("@收费设计");
|
||||||
|
|
||||||
|
fireEvent.dragEnd(document);
|
||||||
|
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.dragEnter(input, { dataTransfer });
|
||||||
|
fireEvent.dragOver(input, { dataTransfer });
|
||||||
|
|
||||||
fireEvent.drop(input, { dataTransfer });
|
fireEvent.drop(input, { dataTransfer });
|
||||||
|
|
||||||
expect(input).toHaveValue("Compare @收费设计 notes");
|
expect(input).toHaveValue("Compare @收费设计 notes");
|
||||||
|
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
|
||||||
expect(screen.getByTestId("composer-session-mention-收费设计"))
|
expect(screen.getByTestId("composer-session-mention-收费设计"))
|
||||||
.toHaveTextContent("@收费设计");
|
.toHaveTextContent("@收费设计");
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user