fix(app): fix perf issues and bugs with textarea autosizing (#20)
This commit is contained in:
parent
692a231d14
commit
3a72b8d3c4
@ -106,6 +106,13 @@ import * as PlaceholderUtils from '~/utils/PlaceholderUtils';
|
||||
import wrapperStyles from './textarea/InputWrapper.module.css';
|
||||
import styles from './textarea/TextareaInput.module.css';
|
||||
|
||||
function readBorderBoxBlockSize(entry: ResizeObserverEntry): number {
|
||||
const bbs: any = (entry as any).borderBoxSize;
|
||||
if (Array.isArray(bbs) && bbs[0] && typeof bbs[0].blockSize === 'number') return bbs[0].blockSize;
|
||||
if (bbs && typeof bbs.blockSize === 'number') return bbs.blockSize;
|
||||
return (entry.target as HTMLElement).getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
const ChannelTextareaContent = observer(
|
||||
({
|
||||
channel,
|
||||
@ -123,7 +130,6 @@ const ChannelTextareaContent = observer(
|
||||
const [isInputAreaFocused, setIsInputAreaFocused] = React.useState(false);
|
||||
const [value, setValue] = React.useState('');
|
||||
const [showAllButtons, setShowAllButtons] = React.useState(true);
|
||||
const [_textareaHeight, setTextareaHeight] = React.useState(0);
|
||||
const [containerWidth, setContainerWidth] = React.useState(0);
|
||||
const [pendingMentionConfirmation, setPendingMentionConfirmation] = React.useState<MentionConfirmationInfo | null>(
|
||||
null,
|
||||
@ -139,6 +145,64 @@ const ChannelTextareaContent = observer(
|
||||
const scrollerRef = React.useRef<ScrollerHandle>(null);
|
||||
useMarkdownKeybinds(isFocused);
|
||||
|
||||
const textareaHeightRef = React.useRef<number>(0);
|
||||
const handleTextareaHeightChange = React.useCallback((height: number) => {
|
||||
textareaHeightRef.current = height;
|
||||
}, []);
|
||||
|
||||
const inputBoxHeightRef = React.useRef<number | null>(null);
|
||||
const pendingLayoutDeltaRef = React.useRef(0);
|
||||
const flushScheduledRef = React.useRef(false);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
inputBoxHeightRef.current = null;
|
||||
pendingLayoutDeltaRef.current = 0;
|
||||
flushScheduledRef.current = false;
|
||||
|
||||
const flush = () => {
|
||||
flushScheduledRef.current = false;
|
||||
const delta = pendingLayoutDeltaRef.current;
|
||||
pendingLayoutDeltaRef.current = 0;
|
||||
if (!delta) return;
|
||||
if (delta <= 0) return;
|
||||
|
||||
ComponentDispatch.dispatch('LAYOUT_RESIZED', {
|
||||
channelId: channel.id,
|
||||
heightDelta: delta,
|
||||
});
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
|
||||
const nextHeight = Math.round(readBorderBoxBlockSize(entry));
|
||||
const prevHeight = inputBoxHeightRef.current;
|
||||
|
||||
if (prevHeight == null) {
|
||||
inputBoxHeightRef.current = nextHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = nextHeight - prevHeight;
|
||||
if (!delta) return;
|
||||
|
||||
inputBoxHeightRef.current = nextHeight;
|
||||
pendingLayoutDeltaRef.current += delta;
|
||||
|
||||
if (!flushScheduledRef.current) {
|
||||
flushScheduledRef.current = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
});
|
||||
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [channel.id]);
|
||||
|
||||
const showGiftButton = AccessibilityStore.showGiftButton;
|
||||
const showGifButton = AccessibilityStore.showGifButton;
|
||||
const showMemesButton = AccessibilityStore.showMemesButton;
|
||||
@ -653,9 +717,15 @@ const ChannelTextareaContent = observer(
|
||||
React.useLayoutEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
let lastWidth = -1;
|
||||
|
||||
const checkButtonVisibility = () => {
|
||||
if (!containerRef.current) return;
|
||||
const containerWidthLocal = containerRef.current.offsetWidth;
|
||||
|
||||
if (containerWidthLocal === lastWidth) return;
|
||||
lastWidth = containerWidthLocal;
|
||||
|
||||
const shouldShowAll = containerWidthLocal > 500;
|
||||
setShowAllButtons(shouldShowAll);
|
||||
setContainerWidth(containerWidthLocal);
|
||||
@ -765,12 +835,12 @@ const ChannelTextareaContent = observer(
|
||||
<Scroller ref={scrollerRef} fade={true} className={styles.scroller} key="channel-textarea-scroller">
|
||||
<div style={{display: 'flex', flexDirection: 'column'}}>
|
||||
<TextareaInputField
|
||||
channelId={channel.id}
|
||||
disabled={disabled}
|
||||
isMobile={mobileLayout.enabled}
|
||||
value={value}
|
||||
placeholder={placeholderText}
|
||||
textareaRef={textareaRef}
|
||||
scrollerRef={scrollerRef}
|
||||
isFocused={isFocused}
|
||||
isAutocompleteAttached={isAutocompleteAttached}
|
||||
autocompleteOptions={autocompleteOptions}
|
||||
@ -787,7 +857,7 @@ const ChannelTextareaContent = observer(
|
||||
handleTextChange(newValue, previousValueRef.current);
|
||||
setValue(newValue);
|
||||
}}
|
||||
onHeightChange={setTextareaHeight}
|
||||
onHeightChange={handleTextareaHeightChange}
|
||||
onCursorMove={onCursorMove}
|
||||
onArrowUp={handleArrowUp}
|
||||
onEnter={handleSubmit}
|
||||
|
||||
@ -221,6 +221,7 @@ export const EditingMessageInput = observer(
|
||||
style={{position: 'absolute', visibility: 'hidden', pointerEvents: 'none'}}
|
||||
/>
|
||||
<TextareaInputField
|
||||
channelId={channel.id}
|
||||
disabled={false}
|
||||
isMobile={mobileLayout.enabled}
|
||||
value={value}
|
||||
|
||||
@ -30,6 +30,10 @@
|
||||
--message-group-spacing: 16px;
|
||||
}
|
||||
|
||||
.nativeAnchor {
|
||||
overflow-anchor: auto !important;
|
||||
}
|
||||
|
||||
.scrollerContainer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@ -718,6 +718,7 @@ export const Messages = observer(function Messages({channel}: {channel: ChannelR
|
||||
{topBar}
|
||||
<div className={styles.scrollerContainer}>
|
||||
<Scroller
|
||||
className={state.isAtBottom ? styles.nativeAnchor : undefined}
|
||||
fade={false}
|
||||
scrollbar="regular"
|
||||
hideThumbWhenWindowBlurred
|
||||
|
||||
@ -72,7 +72,7 @@
|
||||
.textarea {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
overflow: visible;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background-color: transparent;
|
||||
@ -88,6 +88,12 @@
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
@supports (field-sizing: content) {
|
||||
.textarea {
|
||||
field-sizing: content;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@ -21,19 +21,18 @@ import {clsx} from 'clsx';
|
||||
import React from 'react';
|
||||
import * as HighlightActionCreators from '~/actions/HighlightActionCreators';
|
||||
import {type AutocompleteOption, isChannel} from '~/components/channel/Autocomplete';
|
||||
import type {ScrollerHandle} from '~/components/uikit/Scroller';
|
||||
import {useTextareaAutofocus} from '~/hooks/useTextareaAutofocus';
|
||||
import {ComponentDispatch} from '~/lib/ComponentDispatch';
|
||||
import {TextareaAutosize} from '~/lib/TextareaAutosize';
|
||||
import styles from './TextareaInput.module.css';
|
||||
|
||||
interface TextareaInputFieldProps {
|
||||
channelId: string;
|
||||
|
||||
disabled: boolean;
|
||||
isMobile: boolean;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
scrollerRef?: React.RefObject<ScrollerHandle | null> | null;
|
||||
shouldStickToBottomRef?: React.MutableRefObject<boolean>;
|
||||
isFocused?: boolean;
|
||||
isAutocompleteAttached: boolean;
|
||||
@ -60,7 +59,6 @@ export const TextareaInputField = React.forwardRef<HTMLTextAreaElement, Textarea
|
||||
value,
|
||||
placeholder,
|
||||
textareaRef,
|
||||
scrollerRef,
|
||||
isAutocompleteAttached,
|
||||
autocompleteOptions,
|
||||
selectedIndex,
|
||||
@ -75,12 +73,9 @@ export const TextareaInputField = React.forwardRef<HTMLTextAreaElement, Textarea
|
||||
setSelectedIndex,
|
||||
className,
|
||||
onKeyDown,
|
||||
shouldStickToBottomRef,
|
||||
},
|
||||
_ref,
|
||||
) => {
|
||||
const lastHeightRef = React.useRef<number>(0);
|
||||
|
||||
useTextareaAutofocus(textareaRef, isMobile, !disabled);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@ -124,49 +119,6 @@ export const TextareaInputField = React.forwardRef<HTMLTextAreaElement, Textarea
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeightChange = (height: number, meta?: {rowHeight: number}) => {
|
||||
const clampToSingleRow = () => {
|
||||
if (value.length > 0 || !textareaRef.current || !meta?.rowHeight) {
|
||||
return height;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(textareaRef.current);
|
||||
const padding = (parseFloat(style.paddingTop || '0') || 0) + (parseFloat(style.paddingBottom || '0') || 0);
|
||||
const border =
|
||||
(parseFloat(style.borderTopWidth || '0') || 0) + (parseFloat(style.borderBottomWidth || '0') || 0);
|
||||
const singleRowHeight = meta.rowHeight + padding + border;
|
||||
|
||||
if (!Number.isFinite(singleRowHeight) || singleRowHeight <= 0 || singleRowHeight >= height) {
|
||||
return height;
|
||||
}
|
||||
|
||||
textareaRef.current.style.setProperty('height', `${singleRowHeight}px`, 'important');
|
||||
return singleRowHeight;
|
||||
};
|
||||
|
||||
const adjustedHeight = clampToSingleRow();
|
||||
|
||||
if (adjustedHeight === lastHeightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const heightDelta = adjustedHeight - lastHeightRef.current;
|
||||
lastHeightRef.current = adjustedHeight;
|
||||
|
||||
const distanceFromBottom = scrollerRef?.current?.getDistanceFromBottom?.() ?? 0;
|
||||
const shouldStickToBottom = shouldStickToBottomRef?.current ?? distanceFromBottom <= 8;
|
||||
|
||||
onHeightChange(adjustedHeight);
|
||||
|
||||
if (shouldStickToBottom) {
|
||||
scrollerRef?.current?.scrollToBottom({animate: false});
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
ComponentDispatch.dispatch('LAYOUT_RESIZED', {heightDelta});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TextareaAutosize
|
||||
data-channel-textarea
|
||||
@ -176,7 +128,7 @@ export const TextareaInputField = React.forwardRef<HTMLTextAreaElement, Textarea
|
||||
onBlur={onBlur}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onFocus={onFocus}
|
||||
onHeightChange={handleHeightChange}
|
||||
onHeightChange={(h) => onHeightChange(h)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
ref={textareaRef}
|
||||
|
||||
@ -51,16 +51,96 @@ function computeRowConstraints(el: HTMLTextAreaElement, minRows?: number, maxRow
|
||||
};
|
||||
}
|
||||
|
||||
function supportsFieldSizingContent(): boolean {
|
||||
try {
|
||||
return typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('field-sizing: content');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeValueForMeasurement(value: string): string {
|
||||
if (!value.includes('\n')) return value;
|
||||
const parts = value.split('\n');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i] === '') parts[i] = '\u200b';
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function ensureMeasureEl(): HTMLTextAreaElement | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const el = document.createElement('textarea');
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
el.tabIndex = -1;
|
||||
el.style.position = 'absolute';
|
||||
el.style.top = '0';
|
||||
el.style.left = '-9999px';
|
||||
el.style.height = '0px';
|
||||
el.style.overflow = 'hidden';
|
||||
el.style.visibility = 'hidden';
|
||||
el.style.pointerEvents = 'none';
|
||||
el.style.zIndex = '-1';
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function syncMeasureStyles(target: HTMLTextAreaElement, measure: HTMLTextAreaElement) {
|
||||
const cs = window.getComputedStyle(target);
|
||||
|
||||
measure.style.boxSizing = cs.boxSizing;
|
||||
measure.style.width = `${target.getBoundingClientRect().width}px`;
|
||||
|
||||
measure.style.font = cs.font;
|
||||
measure.style.fontFamily = cs.fontFamily;
|
||||
measure.style.fontSize = cs.fontSize;
|
||||
measure.style.fontWeight = cs.fontWeight;
|
||||
measure.style.fontStyle = cs.fontStyle;
|
||||
measure.style.letterSpacing = cs.letterSpacing;
|
||||
measure.style.textTransform = cs.textTransform;
|
||||
measure.style.textRendering = cs.textRendering as any;
|
||||
|
||||
measure.style.lineHeight = cs.lineHeight;
|
||||
measure.style.whiteSpace = cs.whiteSpace;
|
||||
measure.style.wordBreak = cs.wordBreak;
|
||||
measure.style.overflowWrap = (cs as any).overflowWrap ?? 'normal';
|
||||
measure.style.tabSize = (cs as any).tabSize ?? '8';
|
||||
|
||||
measure.style.paddingTop = cs.paddingTop;
|
||||
measure.style.paddingBottom = cs.paddingBottom;
|
||||
measure.style.paddingLeft = cs.paddingLeft;
|
||||
measure.style.paddingRight = cs.paddingRight;
|
||||
|
||||
measure.style.borderTopWidth = cs.borderTopWidth;
|
||||
measure.style.borderBottomWidth = cs.borderBottomWidth;
|
||||
measure.style.borderLeftWidth = cs.borderLeftWidth;
|
||||
measure.style.borderRightWidth = cs.borderRightWidth;
|
||||
measure.style.borderTopStyle = cs.borderTopStyle;
|
||||
measure.style.borderBottomStyle = cs.borderBottomStyle;
|
||||
measure.style.borderLeftStyle = cs.borderLeftStyle;
|
||||
measure.style.borderRightStyle = cs.borderRightStyle;
|
||||
measure.style.borderTopColor = cs.borderTopColor;
|
||||
measure.style.borderBottomColor = cs.borderBottomColor;
|
||||
measure.style.borderLeftColor = cs.borderLeftColor;
|
||||
measure.style.borderRightColor = cs.borderRightColor;
|
||||
|
||||
measure.style.borderRadius = cs.borderRadius;
|
||||
}
|
||||
|
||||
export const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>((props, forwardedRef) => {
|
||||
const {minRows: minRowsProp, maxRows, style, onHeightChange, rows, onInput, ...rest} = props;
|
||||
|
||||
const resolvedRows = rows ?? 1;
|
||||
const minRows = minRowsProp ?? (typeof resolvedRows === 'number' ? resolvedRows : undefined);
|
||||
|
||||
const nativeFieldSizing = supportsFieldSizingContent();
|
||||
|
||||
const elRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const measureRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const onHeightChangeRef = React.useRef(onHeightChange);
|
||||
const lastWidthRef = React.useRef<number | null>(null);
|
||||
const lastHeightRef = React.useRef<number | null>(null);
|
||||
const lastEmittedHeightRef = React.useRef<number | null>(null);
|
||||
const resizeScheduledRef = React.useRef(false);
|
||||
|
||||
const setRef = React.useCallback(
|
||||
@ -76,49 +156,84 @@ export const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaAu
|
||||
onHeightChangeRef.current = onHeightChange;
|
||||
}, [onHeightChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (nativeFieldSizing) return;
|
||||
const measure = ensureMeasureEl();
|
||||
measureRef.current = measure;
|
||||
return () => {
|
||||
measureRef.current?.remove();
|
||||
measureRef.current = null;
|
||||
};
|
||||
}, [nativeFieldSizing]);
|
||||
|
||||
const emitHeightIfChanged = React.useCallback(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const cs = window.getComputedStyle(el);
|
||||
const lineHeight = getLineHeight(cs);
|
||||
const height = Math.round(el.getBoundingClientRect().height);
|
||||
|
||||
if (lastEmittedHeightRef.current !== height) {
|
||||
lastEmittedHeightRef.current = height;
|
||||
onHeightChangeRef.current?.(height, {rowHeight: lineHeight});
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el || (minRows == null && maxRows == null)) return;
|
||||
|
||||
const {minHeight, maxHeight} = computeRowConstraints(el, minRows, maxRows);
|
||||
|
||||
if (minHeight != null) {
|
||||
el.style.minHeight = `${minHeight}px`;
|
||||
}
|
||||
if (maxHeight != null) {
|
||||
el.style.maxHeight = `${maxHeight}px`;
|
||||
}
|
||||
if (minHeight != null) el.style.minHeight = `${minHeight}px`;
|
||||
if (maxHeight != null) el.style.maxHeight = `${maxHeight}px`;
|
||||
}, [minRows, maxRows]);
|
||||
|
||||
const resize = React.useCallback(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
|
||||
if (nativeFieldSizing) {
|
||||
emitHeightIfChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const measure = measureRef.current;
|
||||
if (!measure) return;
|
||||
|
||||
const cs = window.getComputedStyle(el);
|
||||
const {minHeight, maxHeight, lineHeight} = computeRowConstraints(el, minRows, maxRows);
|
||||
const borderBlock = getNumber(cs.borderTopWidth) + getNumber(cs.borderBottomWidth);
|
||||
const isBorderBox = cs.boxSizing === 'border-box';
|
||||
|
||||
el.style.height = 'auto';
|
||||
syncMeasureStyles(el, measure);
|
||||
|
||||
let nextHeight = el.scrollHeight + (isBorderBox ? borderBlock : 0);
|
||||
const measuredValue = normalizeValueForMeasurement(el.value);
|
||||
if (measure.value !== measuredValue) {
|
||||
measure.value = measuredValue;
|
||||
}
|
||||
|
||||
let nextHeight = measure.scrollHeight + (isBorderBox ? borderBlock : 0);
|
||||
if (minHeight != null) nextHeight = Math.max(nextHeight, minHeight);
|
||||
if (maxHeight != null) nextHeight = Math.min(nextHeight, maxHeight);
|
||||
|
||||
const heightPx = `${nextHeight}px`;
|
||||
const heightPx = `${Math.round(nextHeight)}px`;
|
||||
if (el.style.height !== heightPx) {
|
||||
el.style.height = heightPx;
|
||||
}
|
||||
|
||||
if (lastHeightRef.current !== nextHeight) {
|
||||
lastHeightRef.current = nextHeight;
|
||||
onHeightChangeRef.current?.(nextHeight, {rowHeight: lineHeight});
|
||||
const emittedHeight = Math.round(nextHeight);
|
||||
if (lastEmittedHeightRef.current !== emittedHeight) {
|
||||
lastEmittedHeightRef.current = emittedHeight;
|
||||
onHeightChangeRef.current?.(emittedHeight, {rowHeight: lineHeight});
|
||||
}
|
||||
}, [maxRows, minRows]);
|
||||
}, [emitHeightIfChanged, maxRows, minRows, nativeFieldSizing]);
|
||||
|
||||
const scheduleResize = React.useCallback(() => {
|
||||
if (resizeScheduledRef.current) return;
|
||||
resizeScheduledRef.current = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
resizeScheduledRef.current = false;
|
||||
resize();
|
||||
@ -133,16 +248,22 @@ export const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaAu
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
|
||||
const width = entry.borderBoxSize?.[0]?.inlineSize ?? el.getBoundingClientRect().width;
|
||||
const width = (entry as any).borderBoxSize?.[0]?.inlineSize ?? el.getBoundingClientRect().width;
|
||||
|
||||
if (width !== lastWidthRef.current) {
|
||||
lastWidthRef.current = width;
|
||||
scheduleResize();
|
||||
return;
|
||||
}
|
||||
|
||||
if (nativeFieldSizing) {
|
||||
emitHeightIfChanged();
|
||||
}
|
||||
});
|
||||
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [scheduleResize]);
|
||||
}, [emitHeightIfChanged, nativeFieldSizing, scheduleResize]);
|
||||
|
||||
const computedStyle = React.useMemo(
|
||||
(): React.CSSProperties => ({
|
||||
@ -154,15 +275,15 @@ export const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaAu
|
||||
|
||||
const handleInput = React.useCallback(
|
||||
(event: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
resize();
|
||||
scheduleResize();
|
||||
onInput?.(event);
|
||||
},
|
||||
[onInput, resize],
|
||||
[onInput, scheduleResize],
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
resize();
|
||||
}, [resize, props.value, props.defaultValue, rows]);
|
||||
scheduleResize();
|
||||
}, [scheduleResize, props.value, props.defaultValue, rows, minRows, maxRows, nativeFieldSizing]);
|
||||
|
||||
return <textarea {...rest} ref={setRef} rows={resolvedRows} style={computedStyle} onInput={handleInput} />;
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user