mirror of
https://github.com/element-hq/element-web.git
synced 2025-09-17 11:04:05 +02:00
Compare commits
10 Commits
develop
...
langleyd/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e65d4254d | ||
|
|
ce68db5c20 | ||
|
|
919b5ee452 | ||
|
|
7571ea3dd2 | ||
|
|
cc6b3f0c39 | ||
|
|
e567d8039c | ||
|
|
b83a86532b | ||
|
|
e66190304f | ||
|
|
51665017a3 | ||
|
|
c764fc8f2e |
@@ -149,6 +149,7 @@
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"react-virtuoso": "^4.12.6",
|
||||
"rfc4648": "^1.4.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sanitize-html": "2.16.0",
|
||||
@@ -156,6 +157,7 @@
|
||||
"temporal-polyfill": "^0.3.0",
|
||||
"ua-parser-js": "^1.0.2",
|
||||
"uuid": "^11.0.0",
|
||||
"virtua": "^0.41.0",
|
||||
"what-input": "^5.2.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -42,3 +42,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
width: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MemberTileView_hover {
|
||||
background-color: var(--cpd-color-bg-action-secondary-hovered);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ import { isValid3pidInvite } from "../../../RoomInvite";
|
||||
import { type ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite";
|
||||
import { type XOR } from "../../../@types/common";
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
|
||||
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
|
||||
|
||||
@@ -111,6 +113,7 @@ export interface MemberListViewState {
|
||||
shouldShowSearch: boolean;
|
||||
isLoading: boolean;
|
||||
canInvite: boolean;
|
||||
onClickMember: (member: RoomMember | ThreePIDInvite) => void;
|
||||
onInviteButtonClick: (ev: ButtonEvent) => void;
|
||||
}
|
||||
export function useMemberListViewModel(roomId: string): MemberListViewState {
|
||||
@@ -133,6 +136,14 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
|
||||
*/
|
||||
const [memberCount, setMemberCount] = useState(0);
|
||||
|
||||
const onClickMember = (member: RoomMember | ThreePIDInvite): void => {
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: member,
|
||||
push: true,
|
||||
});
|
||||
};
|
||||
|
||||
const loadMembers = useMemo(
|
||||
() =>
|
||||
throttle(
|
||||
@@ -267,6 +278,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
|
||||
isPresenceEnabled,
|
||||
isLoading,
|
||||
onInviteButtonClick,
|
||||
onClickMember,
|
||||
shouldShowSearch: totalMemberCount >= 20,
|
||||
canInvite,
|
||||
};
|
||||
|
||||
@@ -19,10 +19,10 @@ interface TooltipProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const OptionalTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
|
||||
if (canInvite) return children;
|
||||
const InviteTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
|
||||
const description: string = canInvite ? _t("action|invite") : _t("member_list|invite_button_no_perms_tooltip");
|
||||
// If the user isn't allowed to invite others to this room, wrap with a relevant tooltip.
|
||||
return <Tooltip description={_t("member_list|invite_button_no_perms_tooltip")}>{children}</Tooltip>;
|
||||
return <Tooltip description={description}>{children}</Tooltip>;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -42,7 +42,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
if (shouldShowSearch) {
|
||||
/// When rendered alongside a search box, the invite button is just an icon.
|
||||
return (
|
||||
<OptionalTooltip canInvite={vm.canInvite}>
|
||||
<InviteTooltip canInvite={vm.canInvite}>
|
||||
<Button
|
||||
className="mx_MemberListHeaderView_invite_small"
|
||||
kind="primary"
|
||||
@@ -54,13 +54,13 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
aria-label={_t("action|invite")}
|
||||
type="button"
|
||||
/>
|
||||
</OptionalTooltip>
|
||||
</InviteTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Without a search box, invite button is a full size button.
|
||||
return (
|
||||
<OptionalTooltip canInvite={vm.canInvite}>
|
||||
<InviteTooltip canInvite={vm.canInvite}>
|
||||
<Button
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
@@ -72,7 +72,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
>
|
||||
{_t("action|invite")}
|
||||
</Button>
|
||||
</OptionalTooltip>
|
||||
</InviteTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Form } from "@vector-im/compound-web";
|
||||
import React, { type JSX } from "react";
|
||||
import { List, type ListRowProps } from "react-virtualized/dist/commonjs/List";
|
||||
import { AutoSizer } from "react-virtualized";
|
||||
import React, { useCallback, useRef, type JSX } from "react";
|
||||
import { Virtualizer, VirtualizerHandle } from "virtua";
|
||||
// import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
|
||||
import { Flex } from "../../../utils/Flex";
|
||||
import {
|
||||
@@ -21,7 +21,6 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
|
||||
import { MemberListHeaderView } from "./MemberListHeaderView";
|
||||
import BaseCard from "../../right_panel/BaseCard";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
@@ -30,53 +29,65 @@ interface IProps {
|
||||
|
||||
const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||
const vm = useMemberListViewModel(props.roomId);
|
||||
|
||||
const totalRows = vm.members.length;
|
||||
const ref = useRef<VirtualizerHandle | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [focusedIndex, setFocusedIndex] = React.useState(-1);
|
||||
|
||||
const getRowComponent = (item: MemberWithSeparator): JSX.Element => {
|
||||
const getRowComponent = (item: MemberWithSeparator, focused: boolean): JSX.Element => {
|
||||
if (item === SEPARATOR) {
|
||||
return <hr className="mx_MemberListView_separator" />;
|
||||
} else if (item.member) {
|
||||
return <RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />;
|
||||
return <RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} focused={focused} />;
|
||||
} else {
|
||||
return <ThreePidInviteTileView threePidInvite={item.threePidInvite} />;
|
||||
return <ThreePidInviteTileView threePidInvite={item.threePidInvite} focused={focused} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getRowHeight = ({ index }: { index: number }): number => {
|
||||
if (vm.members[index] === SEPARATOR) {
|
||||
/**
|
||||
* This is a separator of 2px height rendered between
|
||||
* joined and invited members.
|
||||
*/
|
||||
return 2;
|
||||
} else if (totalRows && index === totalRows) {
|
||||
/**
|
||||
* The empty spacer div rendered at the bottom should
|
||||
* have a height of 32px.
|
||||
*/
|
||||
return 32;
|
||||
} else {
|
||||
/**
|
||||
* The actual member tiles have a height of 56px.
|
||||
*/
|
||||
return 56;
|
||||
}
|
||||
const scrollToIndex = useCallback(
|
||||
(index: number): void => {
|
||||
ref?.current?.scrollToIndex(index, {
|
||||
align: "nearest",
|
||||
});
|
||||
setFocusedIndex(index);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const keyDownCallback = useCallback(
|
||||
(e: any) => {
|
||||
if (e.code === "ArrowUp") {
|
||||
const nextItemIsSeparator = focusedIndex > 1 && vm.members[focusedIndex - 1] === SEPARATOR;
|
||||
const nextMemberOffset = nextItemIsSeparator ? 2 : 1;
|
||||
scrollToIndex(Math.max(0, focusedIndex - nextMemberOffset));
|
||||
e.preventDefault();
|
||||
} else if (e.code === "ArrowDown") {
|
||||
const nextItemIsSeparator = focusedIndex < totalRows - 1 && vm.members[focusedIndex + 1] === SEPARATOR;
|
||||
const nextMemberOffset = nextItemIsSeparator ? 2 : 1;
|
||||
scrollToIndex(Math.min(totalRows - 1, focusedIndex + nextMemberOffset));
|
||||
e.preventDefault();
|
||||
} else if ((e.code === "Enter" || e.code === "Space") && focusedIndex >= 0) {
|
||||
const item = vm.members[focusedIndex];
|
||||
if (item !== SEPARATOR) {
|
||||
const member = item.member ?? item.threePidInvite;
|
||||
vm.onClickMember(member);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[scrollToIndex, focusedIndex, setFocusedIndex, vm, totalRows],
|
||||
);
|
||||
|
||||
const onFocus = (e: React.FocusEvent): void => {
|
||||
const nextIndex = focusedIndex == -1 ? 0 : focusedIndex;
|
||||
scrollToIndex(nextIndex);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const rowRenderer = ({ key, index, style }: ListRowProps): JSX.Element => {
|
||||
if (index === totalRows) {
|
||||
// We've rendered all the members,
|
||||
// now we render an empty div to add some space to the end of the list.
|
||||
return <div key={key} style={style} />;
|
||||
}
|
||||
const item = vm.members[index];
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
{getRowComponent(item)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
function footer(): React.ReactNode {
|
||||
return <div style={{ height: "32px" }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
@@ -87,34 +98,29 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||
header={_t("common|people")}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<RovingTabIndexProvider handleUpDown scrollIntoView>
|
||||
{({ onKeyDownHandler }) => (
|
||||
<Flex
|
||||
align="stretch"
|
||||
direction="column"
|
||||
className="mx_MemberListView_container"
|
||||
onKeyDown={onKeyDownHandler}
|
||||
>
|
||||
<Form.Root>
|
||||
<MemberListHeaderView vm={vm} />
|
||||
</Form.Root>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
rowRenderer={rowRenderer}
|
||||
rowHeight={getRowHeight}
|
||||
// The +1 refers to the additional empty div that we render at the end of the list.
|
||||
rowCount={totalRows + 1}
|
||||
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
|
||||
height={height - 113}
|
||||
width={width}
|
||||
overscanRowCount={15}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Flex>
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
||||
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
|
||||
<Form.Root>
|
||||
<MemberListHeaderView vm={vm} />
|
||||
</Form.Root>
|
||||
<div
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
// opt out browser's scroll anchoring on header/footer because it will conflict to scroll anchoring of virtualizer
|
||||
overflowAnchor: "none",
|
||||
}}
|
||||
aria-label={_t("room_list|list_title")}
|
||||
role="grid"
|
||||
ref={scrollRef}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={keyDownCallback}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Virtualizer ref={ref} scrollRef={scrollRef}>
|
||||
{vm.members.map((member, index) => getRowComponent(member, index === focusedIndex))}
|
||||
</Virtualizer>
|
||||
{footer()}
|
||||
</div>
|
||||
</Flex>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import React, { useEffect, type JSX } from "react";
|
||||
|
||||
import DisambiguatedProfile from "../../../messages/DisambiguatedProfile";
|
||||
import { type RoomMember } from "../../../../../models/rooms/RoomMember";
|
||||
@@ -20,6 +20,7 @@ import { InvitedIconView } from "./common/InvitedIconView";
|
||||
interface IProps {
|
||||
member: RoomMember;
|
||||
showPresence?: boolean;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
export function RoomMemberTileView(props: IProps): JSX.Element {
|
||||
@@ -30,13 +31,13 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
|
||||
size="32px"
|
||||
name={member.name}
|
||||
idName={member.userId}
|
||||
title={member.displayUserId}
|
||||
title={vm.title}
|
||||
url={member.avatarThumbnailUrl}
|
||||
altText={_t("common|user_avatar")}
|
||||
/>
|
||||
);
|
||||
const name = vm.name;
|
||||
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
|
||||
const nameJSX = <DisambiguatedProfile withTooltip member={member} fallbackName={name || ""} />;
|
||||
|
||||
const presenceState = member.presenceState;
|
||||
let presenceJSX: JSX.Element | undefined;
|
||||
@@ -54,13 +55,14 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<MemberTileView
|
||||
title={vm.title}
|
||||
onClick={vm.onClick}
|
||||
avatarJsx={av}
|
||||
presenceJsx={presenceJSX}
|
||||
nameJsx={nameJSX}
|
||||
userLabel={vm.userLabel}
|
||||
ariaLabel={_t("member_list|open_profile", { memberName: name })}
|
||||
iconJsx={iconJsx}
|
||||
focused={props.focused}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,23 +12,28 @@ import { type ThreePIDInvite } from "../../../../../models/rooms/ThreePIDInvite"
|
||||
import BaseAvatar from "../../../avatars/BaseAvatar";
|
||||
import { MemberTileView } from "./common/MemberTileView";
|
||||
import { InvitedIconView } from "./common/InvitedIconView";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
|
||||
interface Props {
|
||||
threePidInvite: ThreePIDInvite;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
export function ThreePidInviteTileView(props: Props): JSX.Element {
|
||||
const vm = useThreePidTileViewModel(props);
|
||||
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
|
||||
const iconJsx = <InvitedIconView isThreePid={true} />;
|
||||
const name = vm.name;
|
||||
|
||||
return (
|
||||
<MemberTileView
|
||||
nameJsx={vm.name}
|
||||
nameJsx={name}
|
||||
avatarJsx={av}
|
||||
onClick={vm.onClick}
|
||||
ariaLabel={_t("member_list|open_profile", { memberName: name })}
|
||||
userLabel={vm.userLabel}
|
||||
iconJsx={iconJsx}
|
||||
focused={props.focused}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,18 +5,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import classNames from "classnames";
|
||||
import React, { useEffect, useRef, type JSX } from "react";
|
||||
|
||||
import { RovingAccessibleButton } from "../../../../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../../../elements/AccessibleButton";
|
||||
|
||||
interface Props {
|
||||
avatarJsx: JSX.Element;
|
||||
nameJsx: JSX.Element | string;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
ariaLabel: string;
|
||||
presenceJsx?: JSX.Element;
|
||||
userLabel?: React.ReactNode;
|
||||
iconJsx?: JSX.Element;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
export function MemberTileView(props: Props): JSX.Element {
|
||||
@@ -24,11 +26,25 @@ export function MemberTileView(props: Props): JSX.Element {
|
||||
if (props.userLabel) {
|
||||
userLabelJsx = <div className="mx_MemberTileView_userLabel">{props.userLabel}</div>;
|
||||
}
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (props.focused) {
|
||||
ref.current?.focus();
|
||||
}
|
||||
}, [props.focused]);
|
||||
return (
|
||||
// The wrapping div is required to make the magic mouse listener work, for some reason.
|
||||
<div>
|
||||
<RovingAccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
|
||||
<AccessibleButton
|
||||
ref={ref}
|
||||
className={classNames("mx_MemberTileView", {
|
||||
mx_MemberTileView_hover: props.focused,
|
||||
})}
|
||||
onClick={props.onClick}
|
||||
aria-label={props.ariaLabel}
|
||||
tabIndex={props.focused ? 0 : -1}
|
||||
role="gridcell"
|
||||
>
|
||||
<div className="mx_MemberTileView_left">
|
||||
<div className="mx_MemberTileView_avatar">
|
||||
{props.avatarJsx} {props.presenceJsx}
|
||||
@@ -39,7 +55,7 @@ export function MemberTileView(props: Props): JSX.Element {
|
||||
{userLabelJsx}
|
||||
{props.iconJsx}
|
||||
</div>
|
||||
</RovingAccessibleButton>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1645,7 +1645,9 @@
|
||||
"invite_button_no_perms_tooltip": "You do not have permission to invite users",
|
||||
"invited_label": "Invited",
|
||||
"no_matches": "No matches",
|
||||
"power_label": "%(userName)s (power %(powerLevelNumber)s)"
|
||||
"power_label": "%(userName)s (power %(powerLevelNumber)s)",
|
||||
"list_title": "Member list",
|
||||
"open_profile": "Open profile %(memberName)s"
|
||||
},
|
||||
"member_list_back_action_label": "Room members",
|
||||
"message_edit_dialog_title": "Message edits",
|
||||
|
||||
13
yarn.lock
13
yarn.lock
@@ -3722,13 +3722,14 @@
|
||||
|
||||
"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@vector-im/matrix-wysiwyg@2.38.3":
|
||||
version "2.38.3"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.3.tgz#cc54d8b3e9472bcd8e622126ba364ee31952cd8a"
|
||||
integrity sha512-fqo8P55Vc/t0vxpFar9RDJN5gKEjJmzrLo+O4piDbFda6VrRoqrWAtiu0Au0g6B4hRDPKIuFupk8v9Ja7q8Hvg==
|
||||
dependencies:
|
||||
"@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm"
|
||||
"@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm"
|
||||
|
||||
"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1":
|
||||
version "1.14.1"
|
||||
@@ -11060,6 +11061,11 @@ react-virtualized@^9.22.5:
|
||||
prop-types "^15.7.2"
|
||||
react-lifecycles-compat "^3.0.4"
|
||||
|
||||
react-virtuoso@^4.12.6:
|
||||
version "4.12.6"
|
||||
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.12.6.tgz#20fe374d43cce3c9821e29f4cc4d050596d06d01"
|
||||
integrity sha512-bfvS6aCL1ehXmq39KRiz/vxznGUbtA27I5I24TYCe1DhMf84O3aVNCIwrSjYQjkJGJGzY46ihdN8WkYlemuhMQ==
|
||||
|
||||
react@^19.0.0:
|
||||
version "19.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75"
|
||||
@@ -13014,6 +13020,11 @@ vaul@^1.0.0:
|
||||
dependencies:
|
||||
"@radix-ui/react-dialog" "^1.1.1"
|
||||
|
||||
virtua@^0.41.0:
|
||||
version "0.41.0"
|
||||
resolved "https://registry.yarnpkg.com/virtua/-/virtua-0.41.0.tgz#1e3c847baceb14c57e14be36272903f80a95f006"
|
||||
integrity sha512-P8XJhPAz3mNrxAjPc+NRvD8a8+JtInKgHXuvtucThXW93U1ztUKJ2TDCAMaCPRY0CQCR465mYcOf26yQkCHWEw==
|
||||
|
||||
vt-pbf@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/vt-pbf/-/vt-pbf-3.1.3.tgz#68fd150756465e2edae1cc5c048e063916dcfaac"
|
||||
|
||||
Reference in New Issue
Block a user