feat: Option for separate edit mode (#4203)
* stash * wip * cleanup * Remove collaborativeEditing toggle, it will always be on in next release. Flip separateEdit -> seamlessEdit * Clarify language, hide toggle when collaborative editing is disabled * Flip boolean to match, easier to reason about
This commit is contained in:
@@ -4,6 +4,7 @@ import { ThemeProvider } from "styled-components";
|
||||
import { breakpoints } from "@shared/styles";
|
||||
import GlobalStyles from "@shared/styles/globals";
|
||||
import { dark, light, lightMobile, darkMobile } from "@shared/styles/theme";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -28,7 +29,10 @@ const Theme: React.FC = ({ children }) => {
|
||||
<ThemeProvider theme={theme}>
|
||||
<>
|
||||
<GlobalStyles
|
||||
useCursorPointer={auth.user?.preferences?.useCursorPointer !== false}
|
||||
useCursorPointer={auth.user?.getPreference(
|
||||
UserPreference.UseCursorPointer,
|
||||
true
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
|
||||
@@ -296,7 +296,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
);
|
||||
|
||||
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
|
||||
auth.updateTeam(event);
|
||||
auth.team?.updateFromJson(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
|
||||
|
||||
@@ -130,7 +130,7 @@ const useAuthorizedSettingsConfig = () => {
|
||||
name: t("Features"),
|
||||
path: "/settings/features",
|
||||
component: Features,
|
||||
enabled: can.update,
|
||||
enabled: can.update && team.collaborativeEditing,
|
||||
group: t("Team"),
|
||||
icon: BeakerIcon,
|
||||
},
|
||||
|
||||
@@ -263,7 +263,7 @@ function DocumentMenu({
|
||||
type: "route",
|
||||
title: t("Edit"),
|
||||
to: editDocumentUrl(document),
|
||||
visible: !!can.update && !team.collaborativeEditing,
|
||||
visible: !!can.update && !team.seamlessEditing,
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import { TeamPreference, TeamPreferences } from "@shared/types";
|
||||
import BaseModel from "./BaseModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
@@ -51,6 +52,10 @@ class Team extends BaseModel {
|
||||
@observable
|
||||
defaultUserRole: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
preferences: TeamPreferences | null;
|
||||
|
||||
domain: string | null | undefined;
|
||||
|
||||
url: string;
|
||||
@@ -63,6 +68,45 @@ class Team extends BaseModel {
|
||||
get signinMethods(): string {
|
||||
return "SSO";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this team is using a separate editing mode behind an "Edit"
|
||||
* button rather than seamless always-editing.
|
||||
*
|
||||
* @returns True if editing mode is seamless (no button)
|
||||
*/
|
||||
@computed
|
||||
get seamlessEditing(): boolean {
|
||||
return (
|
||||
this.collaborativeEditing &&
|
||||
this.getPreference(TeamPreference.SeamlessEdit, true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a specific preference key, or return the fallback if
|
||||
* none is set.
|
||||
*
|
||||
* @param key The TeamPreference key to retrieve
|
||||
* @param fallback An optional fallback value, defaults to false.
|
||||
* @returns The value
|
||||
*/
|
||||
getPreference(key: TeamPreference, fallback = false): boolean {
|
||||
return this.preferences?.[key] ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value for a specific preference key.
|
||||
*
|
||||
* @param key The TeamPreference key to retrieve
|
||||
* @param value The value to set
|
||||
*/
|
||||
setPreference(key: TeamPreference, value: boolean) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default Team;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { subMinutes } from "date-fns";
|
||||
import { computed, observable } from "mobx";
|
||||
import { now } from "mobx-utils";
|
||||
import type { Role, UserPreferences } from "@shared/types";
|
||||
import type { Role, UserPreference, UserPreferences } from "@shared/types";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
@@ -66,6 +66,31 @@ class User extends ParanoidModel {
|
||||
return "member";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a specific preference key, or return the fallback if
|
||||
* none is set.
|
||||
*
|
||||
* @param key The UserPreference key to retrieve
|
||||
* @param fallback An optional fallback value, defaults to false.
|
||||
* @returns The value
|
||||
*/
|
||||
getPreference(key: UserPreference, fallback = false): boolean {
|
||||
return this.preferences?.[key] ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value for a specific preference key.
|
||||
*
|
||||
* @param key The UserPreference key to retrieve
|
||||
* @param value The value to set
|
||||
*/
|
||||
setPreference(key: UserPreference, value: boolean) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default User;
|
||||
|
||||
@@ -12,7 +12,6 @@ import Logger from "~/utils/Logger";
|
||||
import { NotFoundError, OfflineError } from "~/utils/errors";
|
||||
import history from "~/utils/history";
|
||||
import { matchDocumentEdit } from "~/utils/routeHelpers";
|
||||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
|
||||
type Params = {
|
||||
@@ -65,7 +64,7 @@ function DataLoader({ match, children }: Props) {
|
||||
? documents.getSharedTree(document.id)
|
||||
: undefined;
|
||||
const isEditRoute = match.path === matchDocumentEdit;
|
||||
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
|
||||
const isEditing = isEditRoute || !!auth.team?.seamlessEditing;
|
||||
const can = usePolicy(document?.id);
|
||||
const location = useLocation<LocationState>();
|
||||
|
||||
@@ -176,7 +175,6 @@ function DataLoader({ match, children }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Loading location={location} />
|
||||
{isEditing && !team?.collaborativeEditing && <HideSidebar ui={ui} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -191,7 +189,6 @@ function DataLoader({ match, children }: Props) {
|
||||
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{isEditing && !team.collaborativeEditing && <HideSidebar ui={ui} />}
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
|
||||
@@ -540,7 +540,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
shareId={shareId}
|
||||
isRevision={!!revision}
|
||||
isDraft={document.isDraft}
|
||||
isEditing={!readOnly && !team?.collaborativeEditing}
|
||||
isEditing={!readOnly && !team?.seamlessEditing}
|
||||
isSaving={this.isSaving}
|
||||
isPublishing={this.isPublishing}
|
||||
publishingIsDisabled={
|
||||
|
||||
@@ -220,11 +220,11 @@ function DocumentHeader({
|
||||
<>
|
||||
<ObservingBanner />
|
||||
|
||||
{!isPublishing && isSaving && !team?.collaborativeEditing && (
|
||||
{!isPublishing && isSaving && !team?.seamlessEditing && (
|
||||
<Status>{t("Saving")}…</Status>
|
||||
)}
|
||||
{!isDeleted && !isRevision && <Collaborators document={document} />}
|
||||
{(isEditing || team?.collaborativeEditing) && !isTemplate && isNew && (
|
||||
{(isEditing || team?.seamlessEditing) && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu
|
||||
document={document}
|
||||
@@ -260,10 +260,7 @@ function DocumentHeader({
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
{canEdit &&
|
||||
!team?.collaborativeEditing &&
|
||||
!isRevision &&
|
||||
editAction}
|
||||
{canEdit && !team?.seamlessEditing && !isRevision && editAction}
|
||||
{canEdit && can.createChildDocument && !isRevision && !isMobile && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react";
|
||||
import UiStore from "~/stores/UiStore";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore;
|
||||
};
|
||||
|
||||
class HideSidebar extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.ui.enableEditMode();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.ui.disableEditMode();
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children || null;
|
||||
}
|
||||
}
|
||||
|
||||
export default HideSidebar;
|
||||
@@ -44,7 +44,6 @@ export default function DocumentScene(props: Props) {
|
||||
const urlParts = documentSlug ? documentSlug.split("-") : [];
|
||||
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
|
||||
const key = [urlId, revisionId].join("/");
|
||||
const isMultiplayer = team.collaborativeEditing;
|
||||
|
||||
return (
|
||||
<DataLoader
|
||||
@@ -59,7 +58,7 @@ export default function DocumentScene(props: Props) {
|
||||
|
||||
// TODO: Remove once multiplayer is 100% rollout, SocketPresence will
|
||||
// no longer be required
|
||||
if (isActive && !isMultiplayer) {
|
||||
if (isActive && !team.collaborativeEditing) {
|
||||
return (
|
||||
<SocketPresence documentId={document.id} isEditing={isEditing}>
|
||||
<Document document={document} {...rest} />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { BeakerIcon } from "outline-icons";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import Heading from "~/components/Heading";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
@@ -11,48 +10,28 @@ import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Features() {
|
||||
const { auth, dialogs } = useStores();
|
||||
const { auth } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const [data, setData] = useState({
|
||||
collaborativeEditing: team.collaborativeEditing,
|
||||
});
|
||||
|
||||
const handleChange = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newData = { ...data, [ev.target.name]: ev.target.checked };
|
||||
setData(newData);
|
||||
|
||||
await auth.updateTeam(newData);
|
||||
showToast(t("Settings saved"), {
|
||||
type: "success",
|
||||
});
|
||||
const handlePreferenceChange = async (
|
||||
ev: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const preferences = {
|
||||
...team.preferences,
|
||||
[ev.target.name]: ev.target.checked,
|
||||
};
|
||||
|
||||
const handleCollabDisable = async () => {
|
||||
const newData = { ...data, collaborativeEditing: false };
|
||||
setData(newData);
|
||||
|
||||
await auth.updateTeam(newData);
|
||||
await auth.updateTeam({ preferences });
|
||||
showToast(t("Settings saved"), {
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const handleCollabDisableConfirm = () => {
|
||||
dialogs.openModal({
|
||||
isCentered: true,
|
||||
title: t("Are you sure you want to disable collaborative editing?"),
|
||||
content: (
|
||||
<DisableCollaborativeEditingDialog onSubmit={handleCollabDisable} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Scene title={t("Features")} icon={<BeakerIcon color="currentColor" />}>
|
||||
<Heading>{t("Features")}</Heading>
|
||||
@@ -62,53 +41,24 @@ function Features() {
|
||||
the experience for all team members.
|
||||
</Trans>
|
||||
</Text>
|
||||
{team.collaborativeEditing && (
|
||||
<SettingRow
|
||||
name="collaborativeEditing"
|
||||
label={t("Collaborative editing")}
|
||||
name="seamlessEdit"
|
||||
label={t("Seamless editing")}
|
||||
description={t(
|
||||
"When enabled multiple people can edit documents at the same time with shared presence and live cursors."
|
||||
`When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.`
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
id="collaborativeEditing"
|
||||
name="collaborativeEditing"
|
||||
checked={data.collaborativeEditing}
|
||||
disabled={data.collaborativeEditing && isCloudHosted}
|
||||
onChange={
|
||||
data.collaborativeEditing
|
||||
? handleCollabDisableConfirm
|
||||
: handleChange
|
||||
}
|
||||
id="seamlessEdit"
|
||||
name="seamlessEdit"
|
||||
checked={team.getPreference(TeamPreference.SeamlessEdit, true)}
|
||||
onChange={handlePreferenceChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
function DisableCollaborativeEditingDialog({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={onSubmit}
|
||||
submitText={t("I’m sure – Disable")}
|
||||
danger
|
||||
>
|
||||
<>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Enabling collaborative editing again in the future may cause some
|
||||
documents to revert to this point in time. It is not advised to
|
||||
disable this feature.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Features);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SettingsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { languageOptions } from "@shared/i18n";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
@@ -93,7 +94,7 @@ function Preferences() {
|
||||
<Switch
|
||||
id="useCursorPointer"
|
||||
name="useCursorPointer"
|
||||
checked={user.preferences?.useCursorPointer !== false}
|
||||
checked={user.getPreference(UserPreference.UseCursorPointer, true)}
|
||||
onChange={handlePreferenceChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as Sentry from "@sentry/react";
|
||||
import invariant from "invariant";
|
||||
import { observable, action, computed, autorun, runInAction } from "mobx";
|
||||
import { getCookie, setCookie, removeCookie } from "tiny-cookie";
|
||||
import { TeamPreferences, UserPreferences } from "@shared/types";
|
||||
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Policy from "~/models/Policy";
|
||||
@@ -221,7 +222,7 @@ export default class AuthStore {
|
||||
name?: string;
|
||||
avatarUrl?: string | null;
|
||||
language?: string;
|
||||
preferences?: Record<string, boolean>;
|
||||
preferences?: UserPreferences;
|
||||
}) => {
|
||||
this.isSaving = true;
|
||||
|
||||
@@ -246,6 +247,7 @@ export default class AuthStore {
|
||||
defaultCollectionId?: string | null;
|
||||
subdomain?: string | null | undefined;
|
||||
allowedDomains?: string[] | null | undefined;
|
||||
preferences?: TeamPreferences;
|
||||
}) => {
|
||||
this.isSaving = true;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { has } from "lodash";
|
||||
import { Transaction } from "sequelize";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import env from "@server/env";
|
||||
import { Event, Team, TeamDomain, User } from "@server/models";
|
||||
@@ -24,6 +26,7 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
|
||||
defaultUserRole,
|
||||
inviteRequired,
|
||||
allowedDomains,
|
||||
preferences,
|
||||
} = params;
|
||||
|
||||
const transaction: Transaction = await sequelize.transaction();
|
||||
@@ -101,6 +104,13 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
|
||||
|
||||
team.allowedDomains = newAllowedDomains;
|
||||
}
|
||||
if (preferences) {
|
||||
for (const value of Object.values(TeamPreference)) {
|
||||
if (has(preferences, value)) {
|
||||
team.setPreference(value, Boolean(preferences[value]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changes = team.changed();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
IsUrl,
|
||||
AllowNull,
|
||||
} from "sequelize-typescript";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { CollectionPermission, TeamPreference } from "@shared/types";
|
||||
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
||||
import env from "@server/env";
|
||||
import { generateAvatarUrl } from "@server/utils/avatars";
|
||||
@@ -169,6 +169,35 @@ class Team extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferences that decide behavior for the team.
|
||||
*
|
||||
* @param preference The team preference to set
|
||||
* @param value Sets the preference value
|
||||
* @returns The current team preferences
|
||||
*/
|
||||
public setPreference = (preference: TeamPreference, value: boolean) => {
|
||||
if (!this.preferences) {
|
||||
this.preferences = {};
|
||||
}
|
||||
this.preferences[preference] = value;
|
||||
this.changed("preferences", true);
|
||||
|
||||
return this.preferences;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the passed preference value
|
||||
*
|
||||
* @param preference The user preference to retrieve
|
||||
* @returns The preference value if set, else undefined
|
||||
*/
|
||||
public getPreference = (preference: TeamPreference) => {
|
||||
return !!this.preferences && this.preferences[preference]
|
||||
? this.preferences[preference]
|
||||
: undefined;
|
||||
};
|
||||
|
||||
provisionFirstCollection = async (userId: string) => {
|
||||
await this.sequelize!.transaction(async (transaction) => {
|
||||
const collection = await Collection.create(
|
||||
|
||||
@@ -22,6 +22,7 @@ router.post("team.update", auth(), async (ctx) => {
|
||||
defaultUserRole,
|
||||
inviteRequired,
|
||||
allowedDomains,
|
||||
preferences,
|
||||
} = ctx.body;
|
||||
|
||||
const { user } = ctx.state;
|
||||
@@ -48,6 +49,7 @@ router.post("team.update", auth(), async (ctx) => {
|
||||
defaultUserRole,
|
||||
inviteRequired,
|
||||
allowedDomains,
|
||||
preferences,
|
||||
},
|
||||
user,
|
||||
team,
|
||||
|
||||
@@ -67,6 +67,10 @@ export default function init(app: Koa = new Koa()): Koa {
|
||||
poll: 1000,
|
||||
ignored: ["node_modules", "flow-typed", "server", "build", "__mocks__"],
|
||||
},
|
||||
// Uncomment to test service worker
|
||||
// headers: {
|
||||
// "Service-Worker-Allowed": "/",
|
||||
// },
|
||||
// public path to bind the middleware to
|
||||
// use the same as in webpack
|
||||
publicPath: config.output.publicPath,
|
||||
|
||||
@@ -655,12 +655,9 @@
|
||||
"Requesting Export": "Requesting Export",
|
||||
"Export Data": "Export Data",
|
||||
"Recent exports": "Recent exports",
|
||||
"Are you sure you want to disable collaborative editing?": "Are you sure you want to disable collaborative editing?",
|
||||
"Manage optional and beta features. Changing these settings will affect the experience for all team members.": "Manage optional and beta features. Changing these settings will affect the experience for all team members.",
|
||||
"Collaborative editing": "Collaborative editing",
|
||||
"When enabled multiple people can edit documents at the same time with shared presence and live cursors.": "When enabled multiple people can edit documents at the same time with shared presence and live cursors.",
|
||||
"I’m sure – Disable": "I’m sure – Disable",
|
||||
"Enabling collaborative editing again in the future may cause some documents to revert to this point in time. It is not advised to disable this feature.": "Enabling collaborative editing again in the future may cause some documents to revert to this point in time. It is not advised to disable this feature.",
|
||||
"Seamless editing": "Seamless editing",
|
||||
"When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.": "When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.",
|
||||
"New group": "New group",
|
||||
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
||||
"All groups": "All groups",
|
||||
|
||||
@@ -45,8 +45,17 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
|
||||
| { serviceTeamId: string };
|
||||
|
||||
export enum UserPreference {
|
||||
/** Whether reopening the app should redirect to the last viewed document. */
|
||||
RememberLastPath = "rememberLastPath",
|
||||
/** If web-style hand pointer should be used on interactive elements. */
|
||||
UseCursorPointer = "useCursorPointer",
|
||||
}
|
||||
|
||||
export type UserPreferences = { [key in UserPreference]?: boolean };
|
||||
|
||||
export enum TeamPreference {
|
||||
/** Whether documents have a separate edit mode instead of seamless editing. */
|
||||
SeamlessEdit = "seamlessEdit",
|
||||
}
|
||||
|
||||
export type TeamPreferences = { [key in TeamPreference]?: boolean };
|
||||
|
||||
Reference in New Issue
Block a user