MVVM WidgetContextMenu component to shared component (#31190)
* Create WidgetContextMenu component in shared-components * Modify WidgetMenuContext call (apptile, extensioncard, widgetcard), test and stories * Correctly use new widgetcontextmenu component * WidgetContextMenuViewModel unit test * Lint and add comments * Finalize widgetcontextmenuviewmodel test * fix lint errors * Fix test error * Update playwright screenshots * add userWidget in widgetcontexstmenu props * Fix some a11y issues on playwright * fix linter error widget card * Use new i18n way for share component widget context menu * Add i18n context provider for widget context menu * chore: lint and update snapshot widgetcontextmenu
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -5,6 +5,7 @@
|
||||
"action": {
|
||||
"delete": "Delete",
|
||||
"dismiss": "Dismiss",
|
||||
"edit": "Edit",
|
||||
"explore_rooms": "Explore rooms",
|
||||
"invite": "Invite",
|
||||
"new_conversation": "New conversation",
|
||||
@@ -13,6 +14,7 @@
|
||||
"open_menu": "Open menu",
|
||||
"pause": "Pause",
|
||||
"play": "Play",
|
||||
"remove": "Remove",
|
||||
"retry": "Retry",
|
||||
"search": "Search",
|
||||
"start_chat": "Start chat"
|
||||
@@ -83,5 +85,15 @@
|
||||
"error_downloading_audio": "Error downloading audio",
|
||||
"unnamed_audio": "Unnamed audio"
|
||||
}
|
||||
},
|
||||
"widget": {
|
||||
"context_menu": {
|
||||
"move_left": "Move left",
|
||||
"move_right": "Move right",
|
||||
"remove": "Remove for everyone",
|
||||
"revoke": "Revoke permissions",
|
||||
"screenshot": "Take a picture",
|
||||
"start_audio_stream": "Start audio stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export * from "./room-list/RoomListHeaderView";
|
||||
export * from "./room-list/RoomListSearchView";
|
||||
export * from "./utils/Box";
|
||||
export * from "./utils/Flex";
|
||||
export * from "./right-panel/WidgetContextMenu";
|
||||
export * from "./utils/VirtualizedList";
|
||||
|
||||
// Utils
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
import { IconButton } from "@vector-im/compound-web";
|
||||
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
|
||||
|
||||
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
import {
|
||||
type WidgetContextMenuAction,
|
||||
type WidgetContextMenuSnapshot,
|
||||
WidgetContextMenuView,
|
||||
} from "./WidgetContextMenuView";
|
||||
import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
|
||||
|
||||
type WidgetContextMenuViewModelProps = WidgetContextMenuSnapshot & WidgetContextMenuAction;
|
||||
|
||||
const WidgetContextMenuViewWrapper = ({
|
||||
onStreamAudioClick,
|
||||
onEditClick,
|
||||
onSnapshotClick,
|
||||
onDeleteClick,
|
||||
onRevokeClick,
|
||||
onFinished,
|
||||
onMoveButton,
|
||||
...rest
|
||||
}: WidgetContextMenuViewModelProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
onStreamAudioClick,
|
||||
onEditClick,
|
||||
onSnapshotClick,
|
||||
onDeleteClick,
|
||||
onRevokeClick,
|
||||
onFinished,
|
||||
onMoveButton,
|
||||
});
|
||||
return <WidgetContextMenuView vm={vm} />;
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "RightPanel/WidgetContextMenuView",
|
||||
component: WidgetContextMenuViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
showStreamAudioStreamButton: true,
|
||||
showEditButton: true,
|
||||
showRevokeButton: true,
|
||||
showDeleteButton: true,
|
||||
showSnapshotButton: true,
|
||||
showMoveButtons: [true, true],
|
||||
canModify: true,
|
||||
widgetMessaging: undefined,
|
||||
isMenuOpened: true,
|
||||
trigger: (
|
||||
<IconButton size="24px" aria-label="context menu trigger button" inert={true} tabIndex={-1}>
|
||||
<TriggerIcon />
|
||||
</IconButton>
|
||||
),
|
||||
onStreamAudioClick: fn(),
|
||||
onEditClick: fn(),
|
||||
onSnapshotClick: fn(),
|
||||
onDeleteClick: fn(),
|
||||
onRevokeClick: fn(),
|
||||
onFinished: fn(),
|
||||
onMoveButton: fn(),
|
||||
},
|
||||
} as Meta<typeof WidgetContextMenuViewWrapper>;
|
||||
|
||||
const Template: StoryFn<typeof WidgetContextMenuViewWrapper> = (args) => <WidgetContextMenuViewWrapper {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
export const OnlyBasicModification = Template.bind({});
|
||||
OnlyBasicModification.args = {
|
||||
showSnapshotButton: false,
|
||||
showMoveButtons: [false, false],
|
||||
showStreamAudioStreamButton: false,
|
||||
showEditButton: false,
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { screen, render } from "@test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { IconButton } from "@vector-im/compound-web";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
|
||||
import { describe, vi, expect, it, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
type WidgetContextMenuAction,
|
||||
type WidgetContextMenuSnapshot,
|
||||
WidgetContextMenuView,
|
||||
} from "./WidgetContextMenuView";
|
||||
import * as stories from "./WidgetContextMenuView.stories.tsx";
|
||||
import { MockViewModel } from "../../viewmodel/MockViewModel.ts";
|
||||
import { I18nApi } from "../../utils/I18nApi.ts";
|
||||
import { I18nContext } from "../../utils/i18nContext.ts";
|
||||
|
||||
const { Default, OnlyBasicModification } = composeStories(stories);
|
||||
|
||||
describe("<WidgetContextMenuView />", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders widget contextmenu with all options", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders widget contextmenu without only basic modification", () => {
|
||||
const { container } = render(<OnlyBasicModification />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
const onKeyDown = vi.fn();
|
||||
const togglePlay = vi.fn();
|
||||
const onSeekbarChange = vi.fn();
|
||||
|
||||
const onStreamAudioClick = vi.fn();
|
||||
const onEditClick = vi.fn();
|
||||
const onSnapshotClick = vi.fn();
|
||||
const onDeleteClick = vi.fn();
|
||||
const onRevokeClick = vi.fn();
|
||||
const onFinished = vi.fn();
|
||||
const onMoveButton = vi.fn();
|
||||
class WidgetContextMenuViewModel
|
||||
extends MockViewModel<WidgetContextMenuSnapshot>
|
||||
implements WidgetContextMenuAction
|
||||
{
|
||||
public onKeyDown = onKeyDown;
|
||||
public togglePlay = togglePlay;
|
||||
public onSeekbarChange = onSeekbarChange;
|
||||
|
||||
public onStreamAudioClick = onStreamAudioClick;
|
||||
public onEditClick = onEditClick;
|
||||
public onSnapshotClick = onSnapshotClick;
|
||||
public onDeleteClick = onDeleteClick;
|
||||
public onRevokeClick = onRevokeClick;
|
||||
public onFinished = onFinished;
|
||||
public onMoveButton = onMoveButton;
|
||||
}
|
||||
|
||||
const defaultValue: WidgetContextMenuSnapshot = {
|
||||
showStreamAudioStreamButton: true,
|
||||
showEditButton: true,
|
||||
showRevokeButton: true,
|
||||
showDeleteButton: true,
|
||||
showSnapshotButton: true,
|
||||
showMoveButtons: [true, true],
|
||||
canModify: true,
|
||||
isMenuOpened: true,
|
||||
userWidget: false,
|
||||
trigger: (
|
||||
<IconButton size="24px" aria-label="context menu trigger button">
|
||||
<TriggerIcon />
|
||||
</IconButton>
|
||||
),
|
||||
};
|
||||
|
||||
it("should attach vm methods", async () => {
|
||||
const vm = new WidgetContextMenuViewModel(defaultValue);
|
||||
|
||||
render(<WidgetContextMenuView vm={vm} />, {
|
||||
wrapper: ({ children }) => <I18nContext.Provider value={new I18nApi()}>{children}</I18nContext.Provider>,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Start audio stream" }));
|
||||
expect(onStreamAudioClick).toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Edit" }));
|
||||
expect(onEditClick).toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Take a picture" }));
|
||||
expect(onSnapshotClick).toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Revoke permissions" }));
|
||||
expect(onRevokeClick).toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Remove for everyone" }));
|
||||
expect(onDeleteClick).toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Move left" }));
|
||||
expect(onMoveButton).toHaveBeenCalledWith(-1);
|
||||
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "Move right" }));
|
||||
expect(onMoveButton).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ReactNode, type JSX } from "react";
|
||||
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
||||
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
|
||||
|
||||
import { type ViewModel } from "../../viewmodel/ViewModel.ts";
|
||||
import { useI18n } from "../../utils/i18nContext.ts";
|
||||
import { useViewModel } from "../../viewmodel/useViewModel.ts";
|
||||
|
||||
export interface WidgetContextMenuSnapshot {
|
||||
/**
|
||||
* Indicates if the audio stream button needs to be shown or not
|
||||
* depending on the config value audio_stream_url and widget type jitsi
|
||||
*/
|
||||
showStreamAudioStreamButton: boolean;
|
||||
/**
|
||||
* Indicates if the edit button is shown depending the user permission to modify
|
||||
*/
|
||||
showEditButton: boolean;
|
||||
/**
|
||||
* Indicates if revoke widget button needs to be shown or not
|
||||
*/
|
||||
showRevokeButton: boolean;
|
||||
/**
|
||||
* Indicates if delete widget button needs to be shown or not
|
||||
*/
|
||||
showDeleteButton: boolean;
|
||||
/**
|
||||
* Show take screenshot button or not dependning on config value enableWidgetScreenshots
|
||||
*/
|
||||
showSnapshotButton: boolean;
|
||||
/**
|
||||
* show move widget position button
|
||||
*/
|
||||
showMoveButtons: [boolean, boolean];
|
||||
/**
|
||||
* Indicates if user can modify the widget settings
|
||||
*/
|
||||
canModify: boolean;
|
||||
/**
|
||||
* Indicates if the widget menu is opened or not
|
||||
*/
|
||||
isMenuOpened: boolean;
|
||||
/**
|
||||
* A component that is displayed which trigger the menu to open or close
|
||||
*/
|
||||
trigger: ReactNode;
|
||||
/**
|
||||
* If it's an instance of a user widget
|
||||
*/
|
||||
userWidget: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetContextMenuAction {
|
||||
/**
|
||||
* Function triggered when stream audio is clicked
|
||||
*/
|
||||
onStreamAudioClick: () => Promise<void>;
|
||||
/**
|
||||
* Function triggered when edit button is clicked
|
||||
*/
|
||||
onEditClick: () => void;
|
||||
/**
|
||||
* Function triggered when snapshot button is clicked
|
||||
*/
|
||||
onSnapshotClick: () => void;
|
||||
/**
|
||||
* Function triggered when delete button is clicked
|
||||
*/
|
||||
onDeleteClick: () => void;
|
||||
/**
|
||||
* Function triggered when revoke button is clicked
|
||||
*/
|
||||
onRevokeClick: () => void;
|
||||
/**
|
||||
* Called when the action is finished, to close the menu
|
||||
*/
|
||||
onFinished: () => void;
|
||||
/**
|
||||
* Button used to move up or down in the list the widget position
|
||||
* @param direction 1 or -1
|
||||
*/
|
||||
onMoveButton: (direction: number) => void;
|
||||
}
|
||||
|
||||
export type WidgetContextMenuViewModel = ViewModel<WidgetContextMenuSnapshot> & WidgetContextMenuAction;
|
||||
|
||||
interface WidgetContextMenuViewProps {
|
||||
vm: WidgetContextMenuViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* A context menu component used to display the correct items that needs to be displayed for a widget item menu
|
||||
*/
|
||||
export const WidgetContextMenuView: React.FC<WidgetContextMenuViewProps> = ({ vm }) => {
|
||||
const { translate: _t } = useI18n();
|
||||
|
||||
const {
|
||||
showStreamAudioStreamButton,
|
||||
showEditButton,
|
||||
showSnapshotButton,
|
||||
showDeleteButton,
|
||||
showRevokeButton,
|
||||
showMoveButtons,
|
||||
isMenuOpened,
|
||||
userWidget,
|
||||
trigger,
|
||||
} = useViewModel(vm);
|
||||
|
||||
let streamAudioStreamButton: JSX.Element | undefined;
|
||||
if (showStreamAudioStreamButton) {
|
||||
streamAudioStreamButton = (
|
||||
<MenuItem onSelect={vm.onStreamAudioClick} label={_t("widget|context_menu|start_audio_stream")} />
|
||||
);
|
||||
}
|
||||
|
||||
let editButton: JSX.Element | undefined;
|
||||
if (showEditButton) {
|
||||
editButton = <MenuItem onSelect={vm.onEditClick} label={_t("action|edit")} />;
|
||||
}
|
||||
|
||||
let snapshotButton: JSX.Element | undefined;
|
||||
if (showSnapshotButton) {
|
||||
snapshotButton = <MenuItem onSelect={vm.onSnapshotClick} label={_t("widget|context_menu|screenshot")} />;
|
||||
}
|
||||
|
||||
let deleteButton: JSX.Element | undefined;
|
||||
if (showDeleteButton) {
|
||||
deleteButton = (
|
||||
<MenuItem
|
||||
onSelect={vm.onDeleteClick}
|
||||
label={userWidget ? _t("action|remove") : _t("widget|context_menu|remove")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let revokeButton: JSX.Element | undefined;
|
||||
if (showRevokeButton) {
|
||||
revokeButton = <MenuItem onSelect={vm.onRevokeClick} label={_t("widget|context_menu|revoke")} />;
|
||||
}
|
||||
|
||||
const [showMoveLeftButton, showMoveRightButton] = showMoveButtons;
|
||||
let moveLeftButton: JSX.Element | undefined;
|
||||
if (showMoveLeftButton) {
|
||||
moveLeftButton = <MenuItem onSelect={() => vm.onMoveButton(-1)} label={_t("widget|context_menu|move_left")} />;
|
||||
}
|
||||
|
||||
let moveRightButton: JSX.Element | undefined;
|
||||
if (showMoveRightButton) {
|
||||
moveRightButton = <MenuItem onSelect={() => vm.onMoveButton(1)} label={_t("widget|context_menu|move_right")} />;
|
||||
}
|
||||
|
||||
// Only render menu items when the menu is open to prevent focusable elements in aria-hidden container
|
||||
const renderMenuItems = (): React.ReactNode => {
|
||||
if (!isMenuOpened) return null;
|
||||
return (
|
||||
<>
|
||||
{streamAudioStreamButton}
|
||||
{editButton}
|
||||
{revokeButton}
|
||||
{deleteButton}
|
||||
{snapshotButton}
|
||||
{moveLeftButton}
|
||||
{moveRightButton}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Default trigger icon if no valid trigger element was passed
|
||||
const wrappedTrigger = React.isValidElement(trigger) ? (
|
||||
trigger
|
||||
) : (
|
||||
<IconButton size="24px" aria-label="context menu trigger button" inert={true} tabIndex={-1}>
|
||||
<TriggerIcon />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
title="Widget context menu"
|
||||
open={isMenuOpened}
|
||||
showTitle={false}
|
||||
side="right"
|
||||
align="start"
|
||||
trigger={wrappedTrigger}
|
||||
onOpenChange={vm.onFinished}
|
||||
>
|
||||
{renderMenuItems()}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<WidgetContextMenuView /> > renders widget contextmenu with all options 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
>
|
||||
<button
|
||||
aria-controls="radix-_r_1_"
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="menu"
|
||||
aria-label="context menu trigger button"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="open"
|
||||
id="radix-_r_0_"
|
||||
inert=""
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<WidgetContextMenuView /> > renders widget contextmenu without only basic modification 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
>
|
||||
<button
|
||||
aria-controls="radix-_r_b_"
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="menu"
|
||||
aria-label="context menu trigger button"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
data-state="open"
|
||||
id="radix-_r_a_"
|
||||
inert=""
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export type { WidgetContextMenuSnapshot, WidgetContextMenuViewModel } from "./WidgetContextMenuView";
|
||||
export { WidgetContextMenuView } from "./WidgetContextMenuView";
|
||||
Reference in New Issue
Block a user