mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-15 12:45:11 +08:00
Add formatting buttons for WysisygComposer
This commit is contained in:
parent
b336e18eae
commit
01858354f8
@ -295,6 +295,7 @@
|
||||
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
|
||||
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
|
||||
@import "./views/rooms/_WhoIsTypingTile.pcss";
|
||||
@import "./views/rooms/wysiwyg_composer/_FormattingButtons.pcss";
|
||||
@import "./views/rooms/wysiwyg_composer/_WysiwygComposer.pcss";
|
||||
@import "./views/settings/_AvatarSetting.pcss";
|
||||
@import "./views/settings/_CrossSigningPanel.pcss";
|
||||
|
@ -233,6 +233,17 @@ limitations under the License.
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The wysisyg composer increase the size of the MessageComposer. We temporary move the buttons
|
||||
Soon the dom structure of the MessageComposer will change with the next evolution of the wysiwyg composer
|
||||
and this workaround will disappear
|
||||
*/
|
||||
.mx_MessageComposer_wysiwyg {
|
||||
.mx_MessageComposer_e2eIcon.mx_E2EIcon,.mx_MessageComposer_button, .mx_MessageComposer_sendMessage {
|
||||
margin-top: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MessageComposer_upload::before {
|
||||
mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
|
||||
}
|
||||
|
114
res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss
Normal file
114
res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss
Normal file
@ -0,0 +1,114 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
.mx_FormattingButtons {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
|
||||
.mx_FormattingButtons_Button {
|
||||
--size: 26px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
height: var(--size);
|
||||
line-height: var(--size);
|
||||
width: auto;
|
||||
padding-left: var(--size);
|
||||
margin-right: 6px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
&:last-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 7px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
background-color: $icon-button-color;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
mask-position: center;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::after {
|
||||
background: rgba($secondary-content, 0.1);
|
||||
}
|
||||
|
||||
&::before {
|
||||
background-color: $secondary-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_FormattingButtons_active {
|
||||
&::after {
|
||||
background: rgba($accent, 0.1);
|
||||
}
|
||||
|
||||
&::before {
|
||||
background-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_FormattingButtons_Button_bold::before {
|
||||
mask-image: url('$(res)/img/element-icons/room/composer/bold.svg');
|
||||
}
|
||||
|
||||
.mx_FormattingButtons_Button_italic::before {
|
||||
mask-image: url('$(res)/img/element-icons/room/composer/italic.svg');
|
||||
}
|
||||
|
||||
.mx_FormattingButtons_Button_underline::before {
|
||||
mask-image: url('$(res)/img/element-icons/room/composer/underline.svg');
|
||||
}
|
||||
|
||||
.mx_FormattingButtons_Button_strike-through::before {
|
||||
mask-image: url('$(res)/img/element-icons/room/composer/strike_through.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_FormattingButtons_Tooltip {
|
||||
padding: 0 2px 0 2px;
|
||||
|
||||
.mx_FormattingButtons_Tooltip_KeyboardShortcut {
|
||||
color: $tertiary-content;
|
||||
|
||||
kbd {
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
text-transform: capitalize;
|
||||
font-size: 12px;
|
||||
font-family: Inter, sans-serif;
|
||||
}
|
||||
}
|
||||
}
|
3
res/img/element-icons/room/composer/bold.svg
Normal file
3
res/img/element-icons/room/composer/bold.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="9" height="12" viewBox="0 0 9 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1C0 0.447715 0.447715 0 1 0H4.19231C6.09295 0 7.5 1.64388 7.5 3.5C7.5 4.25349 7.26813 4.97201 6.86549 5.55977C7.84346 6.1788 8.5 7.25485 8.5 8.5C8.5 10.4594 6.87427 12 4.92857 12H1C0.447715 12 0 11.5523 0 11V1ZM2 2V5H4.19231C4.84067 5 5.5 4.4053 5.5 3.5C5.5 2.5947 4.84067 2 4.19231 2H2ZM2 7V10H4.92857C5.82319 10 6.5 9.30206 6.5 8.5C6.5 7.69794 5.82319 7 4.92857 7H2Z" fill="#737D8C"/>
|
||||
</svg>
|
After Width: | Height: | Size: 544 B |
3
res/img/element-icons/room/composer/italic.svg
Normal file
3
res/img/element-icons/room/composer/italic.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="8" height="13" viewBox="0 0 8 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.08599 1.60016L2.28071 10.4045H0.8C0.358172 10.4045 0 10.7627 0 11.2045C0 11.6464 0.358172 12.0045 0.8 12.0045H2.92107C2.92982 12.0047 2.93855 12.0047 2.94725 12.0045H5.06667C5.50849 12.0045 5.86667 11.6464 5.86667 11.2045C5.86667 10.7627 5.50849 10.4045 5.06667 10.4045H3.914L5.71927 1.60016H7.2C7.64183 1.60016 8 1.24199 8 0.800158C8 0.358331 7.64183 0.00015831 7.2 0.00015831H5.08171C5.0711 -5.33571e-05 5.06051 -5.22589e-05 5.04996 0.00015831H2.93333C2.4915 0.00015831 2.13333 0.358331 2.13333 0.800158C2.13333 1.24199 2.4915 1.60016 2.93333 1.60016H4.08599Z" fill="#8E99A4"/>
|
||||
</svg>
|
After Width: | Height: | Size: 733 B |
4
res/img/element-icons/room/composer/strike_through.svg
Normal file
4
res/img/element-icons/room/composer/strike_through.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.93167 2.76958C7.48979 1.88101 6.58375 1.47362 5.58232 1.58148C4.03349 1.74829 3.62648 2.94831 3.81497 3.72822C4.02118 4.58143 4.69765 4.91317 5.89252 5.21717H11.28C11.6776 5.21717 12 5.56757 12 5.99981C12 6.43204 11.6776 6.78244 11.28 6.78244H0.72C0.322355 6.78244 0 6.43204 0 5.99981C0 5.56757 0.322355 5.21717 0.72 5.21717H2.90308C2.69392 4.91824 2.52701 4.55948 2.42223 4.12592C2.0021 2.38757 3.03605 0.282791 5.44033 0.0238381C6.85635 -0.128674 8.41032 0.440447 9.19844 2.02524C9.38753 2.40548 9.25724 2.88035 8.90743 3.08589C8.55763 3.29143 8.12076 3.14981 7.93167 2.76958Z" fill="#8E99A4"/>
|
||||
<path d="M8.28458 8.08683H9.77971C9.92538 8.87051 9.8142 9.70668 9.36651 10.4212C8.74261 11.4169 7.57984 12 5.98987 12C3.38435 12 2.18628 10.3895 1.94151 9.32405C1.84516 8.90469 2.07981 8.47984 2.4656 8.37511C2.8514 8.27038 3.24225 8.52544 3.3386 8.9448C3.41285 9.268 4.00136 10.4347 5.98987 10.4347C7.27118 10.4347 7.90296 9.97636 8.17634 9.54006C8.42836 9.13783 8.47197 8.60621 8.28458 8.08683Z" fill="#8E99A4"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
3
res/img/element-icons/room/composer/underline.svg
Normal file
3
res/img/element-icons/room/composer/underline.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="10" height="12" viewBox="0 0 10 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.19333 9.3C7.21333 9.04 8.66667 7.22667 8.66667 5.19333V0.833333C8.66667 0.373333 8.29333 0 7.83333 0C7.37333 0 7 0.373333 7 0.833333V5.26667C7 6.38 6.24667 7.39333 5.15333 7.61333C3.65333 7.92667 2.33333 6.78 2.33333 5.33333V0.833333C2.33333 0.373333 1.96 0 1.5 0C1.04 0 0.666667 0.373333 0.666667 0.833333V5.33333C0.666667 7.71333 2.75333 9.61333 5.19333 9.3ZM0 11.3333C0 11.7 0.3 12 0.666667 12H8.66667C9.03333 12 9.33333 11.7 9.33333 11.3333C9.33333 10.9667 9.03333 10.6667 8.66667 10.6667H0.666667C0.3 10.6667 0 10.9667 0 11.3333Z" fill="#8E99A4"/>
|
||||
</svg>
|
After Width: | Height: | Size: 668 B |
@ -389,6 +389,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
public render() {
|
||||
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
|
||||
const controls = [
|
||||
this.props.e2eStatus ?
|
||||
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
|
||||
@ -403,8 +404,6 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||
|
||||
const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
|
||||
if (canSendMessages) {
|
||||
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
|
||||
|
||||
if (isWysiwygComposerEnabled) {
|
||||
controls.push(
|
||||
<WysiwygComposer key="controls_input"
|
||||
@ -503,6 +502,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||
"mx_MessageComposer": true,
|
||||
"mx_MessageComposer--compact": this.props.compact,
|
||||
"mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined,
|
||||
"mx_MessageComposer_wysiwyg": isWysiwygComposerEnabled,
|
||||
});
|
||||
|
||||
return (
|
||||
|
41
src/components/views/rooms/wysiwyg_composer/Editor.tsx
Normal file
41
src/components/views/rooms/wysiwyg_composer/Editor.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 React, { forwardRef, memo } from 'react';
|
||||
|
||||
interface EditorProps {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const Editor = memo(
|
||||
forwardRef<HTMLDivElement, EditorProps>(
|
||||
function Editor({ disabled }: EditorProps, ref,
|
||||
) {
|
||||
return <div className="mx_WysiwygComposer_container">
|
||||
<div className="mx_WysiwygComposer_content"
|
||||
ref={ref}
|
||||
contentEditable={!disabled}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
dir="auto"
|
||||
aria-disabled={disabled}
|
||||
/>
|
||||
</div>;
|
||||
},
|
||||
),
|
||||
);
|
@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 React from "react";
|
||||
import { useWysiwyg } from "@matrix-org/matrix-wysiwyg";
|
||||
import classNames from "classnames";
|
||||
|
||||
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
|
||||
import { Alignment } from "../../elements/Tooltip";
|
||||
import { KeyboardShortcut } from "../../settings/KeyboardShortcut";
|
||||
import { KeyCombo } from "../../../../KeyBindingsManager";
|
||||
import { _td } from "../../../../languageHandler";
|
||||
|
||||
interface TooltipProps {
|
||||
label: string;
|
||||
keyCombo?: KeyCombo;
|
||||
}
|
||||
|
||||
function Tooltip({ label, keyCombo }: TooltipProps) {
|
||||
return <div className="mx_FormattingButtons_Tooltip">
|
||||
{ label }
|
||||
{ keyCombo && <KeyboardShortcut value={keyCombo} className="mx_FormattingButtons_Tooltip_KeyboardShortcut" /> }
|
||||
</div>;
|
||||
}
|
||||
|
||||
interface ButtonProps extends TooltipProps {
|
||||
className: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps) {
|
||||
return <AccessibleTooltipButton
|
||||
element="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
className={
|
||||
classNames('mx_FormattingButtons_Button', className, { 'mx_FormattingButtons_active': isActive })}
|
||||
tooltip={keyCombo && <Tooltip label={label} keyCombo={keyCombo} />}
|
||||
alignment={Alignment.Top}
|
||||
/>;
|
||||
}
|
||||
|
||||
interface FormattingButtonsProps {
|
||||
wysiwyg: ReturnType<typeof useWysiwyg>['wysiwyg'];
|
||||
formattingStates: ReturnType<typeof useWysiwyg>['formattingStates'];
|
||||
}
|
||||
|
||||
export function FormattingButtons({ wysiwyg, formattingStates }: FormattingButtonsProps) {
|
||||
return <div className="mx_FormattingButtons">
|
||||
<Button isActive={formattingStates.bold === 'reversed'} label={_td("Bold")} keyCombo={{ ctrlOrCmdKey: true, key: 'b' }} onClick={() => wysiwyg.bold()} className="mx_FormattingButtons_Button_bold" />
|
||||
<Button isActive={formattingStates.italic === 'reversed'} label={_td('Italic')} keyCombo={{ ctrlOrCmdKey: true, key: 'i' }} onClick={() => wysiwyg.italic()} className="mx_FormattingButtons_Button_italic" />
|
||||
<Button isActive={formattingStates.underline === 'reversed'} label={_td('Underline')} keyCombo={{ ctrlOrCmdKey: true, key: 'u' }} onClick={() => wysiwyg.underline()} className="mx_FormattingButtons_Button_underline" />
|
||||
<Button isActive={formattingStates.strikeThrough === 'reversed'} label={_td('Strike through')} onClick={() => wysiwyg.strikeThrough()} className="mx_FormattingButtons_Button_strike-through" />
|
||||
</div>;
|
||||
}
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useWysiwyg } from "@matrix-org/matrix-wysiwyg";
|
||||
import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
|
||||
@ -22,6 +22,8 @@ import { useRoomContext } from '../../../../contexts/RoomContext';
|
||||
import { sendMessage } from './message';
|
||||
import { RoomPermalinkCreator } from '../../../../utils/permalinks/Permalinks';
|
||||
import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext';
|
||||
import { FormattingButtons } from './FormattingButtons';
|
||||
import { Editor } from './Editor';
|
||||
|
||||
interface WysiwygProps {
|
||||
disabled?: boolean;
|
||||
@ -39,11 +41,13 @@ export function WysiwygComposer(
|
||||
const roomContext = useRoomContext();
|
||||
const mxClient = useMatrixClientContext();
|
||||
|
||||
const [content, setContent] = useState<string>();
|
||||
const { ref, isWysiwygReady, wysiwyg } = useWysiwyg({ onChange: (_content) => {
|
||||
setContent(_content);
|
||||
onChange(_content);
|
||||
} });
|
||||
const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg();
|
||||
|
||||
useEffect(() => {
|
||||
if (content !== null) {
|
||||
onChange(content);
|
||||
}
|
||||
}, [onChange, content]);
|
||||
|
||||
const memoizedSendMessage = useCallback(() => {
|
||||
sendMessage(content, { mxClient, roomContext, ...props });
|
||||
@ -53,18 +57,8 @@ export function WysiwygComposer(
|
||||
|
||||
return (
|
||||
<div className="mx_WysiwygComposer">
|
||||
<div className="mx_WysiwygComposer_container">
|
||||
<div className="mx_WysiwygComposer_content"
|
||||
ref={ref}
|
||||
contentEditable={!disabled && isWysiwygReady}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
dir="auto"
|
||||
aria-disabled={disabled || !isWysiwygReady}
|
||||
/>
|
||||
</div>
|
||||
<FormattingButtons wysiwyg={wysiwyg} formattingStates={formattingStates} />
|
||||
<Editor ref={ref} disabled={!isWysiwygReady || disabled} />
|
||||
{ children?.(memoizedSendMessage) }
|
||||
</div>
|
||||
);
|
||||
|
@ -38,9 +38,10 @@ export const KeyboardKey: React.FC<IKeyboardKeyProps> = ({ name, last }) => {
|
||||
|
||||
interface IKeyboardShortcutProps {
|
||||
value: KeyCombo;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const KeyboardShortcut: React.FC<IKeyboardShortcutProps> = ({ value }) => {
|
||||
export const KeyboardShortcut: React.FC<IKeyboardShortcutProps> = ({ value, className = 'mx_KeyboardShortcut' }) => {
|
||||
if (!value) return null;
|
||||
|
||||
const modifiersElement = [];
|
||||
@ -58,7 +59,7 @@ export const KeyboardShortcut: React.FC<IKeyboardShortcutProps> = ({ value }) =>
|
||||
modifiersElement.push(<KeyboardKey key="shiftKey" name={Key.SHIFT} />);
|
||||
}
|
||||
|
||||
return <div className="mx_KeyboardShortcut">
|
||||
return <div className={className}>
|
||||
{ modifiersElement }
|
||||
<KeyboardKey name={value.key} last />
|
||||
</div>;
|
||||
|
@ -901,7 +901,7 @@
|
||||
"How can I leave the beta?": "How can I leave the beta?",
|
||||
"To leave, return to this page and use the “%(leaveTheBeta)s” button.": "To leave, return to this page and use the “%(leaveTheBeta)s” button.",
|
||||
"Leave the beta": "Leave the beta",
|
||||
"Wysiwyg composer (plain text mode coming soon) (under active development)": "Wysiwyg composer (plain text mode coming soon) (under active development)",
|
||||
"Try out the rich text editor (plain text mode coming soon)": "Try out the rich text editor (plain text mode coming soon)",
|
||||
"Render simple counters in room header": "Render simple counters in room header",
|
||||
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
|
||||
"Support adding custom themes": "Support adding custom themes",
|
||||
@ -2054,6 +2054,9 @@
|
||||
"No microphone found": "No microphone found",
|
||||
"We didn't find a microphone on your device. Please check your settings and try again.": "We didn't find a microphone on your device. Please check your settings and try again.",
|
||||
"Stop recording": "Stop recording",
|
||||
"Italic": "Italic",
|
||||
"Underline": "Underline",
|
||||
"Strike through": "Strike through",
|
||||
"Error updating main address": "Error updating main address",
|
||||
"There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.",
|
||||
"There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.",
|
||||
|
@ -306,7 +306,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
||||
"feature_wysiwyg_composer": {
|
||||
isFeature: true,
|
||||
labsGroup: LabGroup.Messaging,
|
||||
displayName: _td("Wysiwyg composer (plain text mode coming soon) (under active development)"),
|
||||
displayName: _td("Try out the rich text editor (plain text mode coming soon)"),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user