mirror of
https://github.com/element-hq/element-web
synced 2024-11-27 19:56:47 +03:00
Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/ts/c4
Conflicts: src/components/structures/RoomDirectory.tsx src/components/views/room_settings/RoomPublishSetting.tsx
This commit is contained in:
commit
5a80530eff
32 changed files with 403 additions and 159 deletions
6
__mocks__/FontManager.js
Normal file
6
__mocks__/FontManager.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
// Stub out FontManager for tests as it doesn't validate anything we don't already know given
|
||||||
|
// our fixed test environment and it requires the installation of node-canvas.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fixupColorFonts: () => Promise.resolve(),
|
||||||
|
};
|
|
@ -126,6 +126,7 @@
|
||||||
"@types/classnames": "^2.2.11",
|
"@types/classnames": "^2.2.11",
|
||||||
"@types/commonmark": "^0.27.4",
|
"@types/commonmark": "^0.27.4",
|
||||||
"@types/counterpart": "^0.18.1",
|
"@types/counterpart": "^0.18.1",
|
||||||
|
"@types/css-font-loading-module": "^0.0.6",
|
||||||
"@types/diff-match-patch": "^1.0.32",
|
"@types/diff-match-patch": "^1.0.32",
|
||||||
"@types/flux": "^3.1.9",
|
"@types/flux": "^3.1.9",
|
||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^26.0.20",
|
||||||
|
|
|
@ -72,7 +72,7 @@ limitations under the License.
|
||||||
|
|
||||||
.mx_AccessibleButton_kind_danger_outline {
|
.mx_AccessibleButton_kind_danger_outline {
|
||||||
color: $button-danger-bg-color;
|
color: $button-danger-bg-color;
|
||||||
background-color: $button-secondary-bg-color;
|
background-color: transparent;
|
||||||
border: 1px solid $button-danger-bg-color;
|
border: 1px solid $button-danger-bg-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
2
src/@types/global.d.ts
vendored
2
src/@types/global.d.ts
vendored
|
@ -15,6 +15,8 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
|
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
|
||||||
|
// Load types for the WG CSS Font Loading APIs https://github.com/Microsoft/TypeScript/issues/13569
|
||||||
|
import "@types/css-font-loading-module";
|
||||||
import "@types/modernizr";
|
import "@types/modernizr";
|
||||||
|
|
||||||
import ContentMessages from "../ContentMessages";
|
import ContentMessages from "../ContentMessages";
|
||||||
|
|
|
@ -60,6 +60,8 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
||||||
|
|
||||||
|
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Return true if the given string contains emoji
|
* Return true if the given string contains emoji
|
||||||
* Uses a much, much simpler regex than emojibase's so will give false
|
* Uses a much, much simpler regex than emojibase's so will give false
|
||||||
|
@ -176,18 +178,31 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
|
||||||
return { tagName, attribs };
|
return { tagName, attribs };
|
||||||
},
|
},
|
||||||
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||||
|
let src = attribs.src;
|
||||||
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
|
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
|
||||||
// because transformTags is used _before_ we filter by allowedSchemesByTag and
|
// because transformTags is used _before_ we filter by allowedSchemesByTag and
|
||||||
// we don't want to allow images with `https?` `src`s.
|
// we don't want to allow images with `https?` `src`s.
|
||||||
// We also drop inline images (as if they were not present at all) when the "show
|
// We also drop inline images (as if they were not present at all) when the "show
|
||||||
// images" preference is disabled. Future work might expose some UI to reveal them
|
// images" preference is disabled. Future work might expose some UI to reveal them
|
||||||
// like standalone image events have.
|
// like standalone image events have.
|
||||||
if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) {
|
if (!src || !SettingsStore.getValue("showImages")) {
|
||||||
return { tagName, attribs: {} };
|
return { tagName, attribs: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!src.startsWith("mxc://")) {
|
||||||
|
const match = MEDIA_API_MXC_REGEX.exec(src);
|
||||||
|
if (match) {
|
||||||
|
src = `mxc://${match[1]}/${match[2]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!src.startsWith("mxc://")) {
|
||||||
|
return { tagName, attribs: {} };
|
||||||
|
}
|
||||||
|
|
||||||
const width = Number(attribs.width) || 800;
|
const width = Number(attribs.width) || 800;
|
||||||
const height = Number(attribs.height) || 600;
|
const height = Number(attribs.height) || 600;
|
||||||
attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height);
|
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height);
|
||||||
return { tagName, attribs };
|
return { tagName, attribs };
|
||||||
},
|
},
|
||||||
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||||
|
|
14
src/Rooms.ts
14
src/Rooms.ts
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
|
||||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||||
|
import AliasCustomisations from './customisations/Alias';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a room object, return the alias we should use for it,
|
* Given a room object, return the alias we should use for it,
|
||||||
|
@ -28,7 +29,18 @@ import { MatrixClientPeg } from './MatrixClientPeg';
|
||||||
* @returns {string} A display alias for the given room
|
* @returns {string} A display alias for the given room
|
||||||
*/
|
*/
|
||||||
export function getDisplayAliasForRoom(room: Room): string {
|
export function getDisplayAliasForRoom(room: Room): string {
|
||||||
return room.getCanonicalAlias() || room.getAltAliases()[0];
|
return getDisplayAliasForAliasSet(
|
||||||
|
room.getCanonicalAlias(), room.getAltAliases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The various display alias getters should all feed through this one path so
|
||||||
|
// there's a single place to change the logic.
|
||||||
|
export function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string {
|
||||||
|
if (AliasCustomisations.getDisplayAliasForAliasSet) {
|
||||||
|
return AliasCustomisations.getDisplayAliasForAliasSet(canonicalAlias, altAliases);
|
||||||
|
}
|
||||||
|
return canonicalAlias || altAliases?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean {
|
export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean {
|
||||||
|
|
|
@ -447,7 +447,8 @@ function textForPowerEvent(event): () => string | null {
|
||||||
!event.getContent() || !event.getContent().users) {
|
!event.getContent() || !event.getContent().users) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const userDefault = event.getContent().users_default || 0;
|
const previousUserDefault = event.getPrevContent().users_default || 0;
|
||||||
|
const currentUserDefault = event.getContent().users_default || 0;
|
||||||
// Construct set of userIds
|
// Construct set of userIds
|
||||||
const users = [];
|
const users = [];
|
||||||
Object.keys(event.getContent().users).forEach(
|
Object.keys(event.getContent().users).forEach(
|
||||||
|
@ -463,9 +464,16 @@ function textForPowerEvent(event): () => string | null {
|
||||||
const diffs = [];
|
const diffs = [];
|
||||||
users.forEach((userId) => {
|
users.forEach((userId) => {
|
||||||
// Previous power level
|
// Previous power level
|
||||||
const from = event.getPrevContent().users[userId];
|
let from = event.getPrevContent().users[userId];
|
||||||
|
if (!Number.isInteger(from)) {
|
||||||
|
from = previousUserDefault;
|
||||||
|
}
|
||||||
// Current power level
|
// Current power level
|
||||||
const to = event.getContent().users[userId];
|
let to = event.getContent().users[userId];
|
||||||
|
if (!Number.isInteger(to)) {
|
||||||
|
to = currentUserDefault;
|
||||||
|
}
|
||||||
|
if (from === previousUserDefault && to === currentUserDefault) { return; }
|
||||||
if (to !== from) {
|
if (to !== from) {
|
||||||
diffs.push({ userId, from, to });
|
diffs.push({ userId, from, to });
|
||||||
}
|
}
|
||||||
|
@ -479,8 +487,8 @@ function textForPowerEvent(event): () => string | null {
|
||||||
powerLevelDiffText: diffs.map(diff =>
|
powerLevelDiffText: diffs.map(diff =>
|
||||||
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
|
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
|
||||||
userId: diff.userId,
|
userId: diff.userId,
|
||||||
fromPowerLevel: Roles.textualPowerLevel(diff.from, userDefault),
|
fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault),
|
||||||
toPowerLevel: Roles.textualPowerLevel(diff.to, userDefault),
|
toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault),
|
||||||
}),
|
}),
|
||||||
).join(", "),
|
).join(", "),
|
||||||
});
|
});
|
||||||
|
|
|
@ -46,6 +46,7 @@ import DirectorySearchBox from "../views/elements/DirectorySearchBox";
|
||||||
import ScrollPanel from "./ScrollPanel";
|
import ScrollPanel from "./ScrollPanel";
|
||||||
import Spinner from "../views/elements/Spinner";
|
import Spinner from "../views/elements/Spinner";
|
||||||
import { ActionPayload } from "../../dispatcher/payloads";
|
import { ActionPayload } from "../../dispatcher/payloads";
|
||||||
|
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 80;
|
const MAX_NAME_LENGTH = 80;
|
||||||
const MAX_TOPIC_LENGTH = 800;
|
const MAX_TOPIC_LENGTH = 800;
|
||||||
|
@ -833,5 +834,5 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||||
// but works with the objects we get from the public room list
|
// but works with the objects we get from the public room list
|
||||||
function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
||||||
return room.canonical_alias || room.aliases?.[0] || "";
|
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,6 +43,7 @@ import { useStateToggle } from "../../hooks/useStateToggle";
|
||||||
import { getChildOrder } from "../../stores/SpaceStore";
|
import { getChildOrder } from "../../stores/SpaceStore";
|
||||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||||
import { linkifyElement } from "../../HtmlUtils";
|
import { linkifyElement } from "../../HtmlUtils";
|
||||||
|
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||||
|
|
||||||
interface IHierarchyProps {
|
interface IHierarchyProps {
|
||||||
space: Room;
|
space: Room;
|
||||||
|
@ -637,5 +638,5 @@ export default SpaceRoomDirectory;
|
||||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||||
// but works with the objects we get from the public room list
|
// but works with the objects we get from the public room list
|
||||||
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
|
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
|
||||||
return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
|
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||||
}
|
}
|
||||||
|
|
|
@ -238,6 +238,7 @@ export default class AppTile extends React.Component {
|
||||||
case 'm.sticker':
|
case 'm.sticker':
|
||||||
if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
|
if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
|
||||||
dis.dispatch({ action: 'post_sticker_message', data: payload.data });
|
dis.dispatch({ action: 'post_sticker_message', data: payload.data });
|
||||||
|
dis.dispatch({ action: 'stickerpicker_close' });
|
||||||
} else {
|
} else {
|
||||||
console.warn('Ignoring sticker message. Invalid capability');
|
console.warn('Ignoring sticker message. Invalid capability');
|
||||||
}
|
}
|
||||||
|
|
|
@ -244,7 +244,11 @@ export default class TextualBody extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private highlightCode(code: HTMLElement): void {
|
private highlightCode(code: HTMLElement): void {
|
||||||
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
|
// Auto-detect language only if enabled and only for codeblocks
|
||||||
|
if (
|
||||||
|
SettingsStore.getValue("enableSyntaxHighlightLanguageDetection") &&
|
||||||
|
code.parentElement instanceof HTMLPreElement
|
||||||
|
) {
|
||||||
highlight.highlightBlock(code);
|
highlight.highlightBlock(code);
|
||||||
} else {
|
} else {
|
||||||
// Only syntax highlight if there's a class starting with language-
|
// Only syntax highlight if there's a class starting with language-
|
||||||
|
|
|
@ -20,6 +20,7 @@ import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
import DirectoryCustomisations from '../../../customisations/Directory';
|
||||||
import { Visibility } from "matrix-js-sdk/src/@types/partials";
|
import { Visibility } from "matrix-js-sdk/src/@types/partials";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
@ -67,10 +68,15 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
|
||||||
render() {
|
render() {
|
||||||
const client = MatrixClientPeg.get();
|
const client = MatrixClientPeg.get();
|
||||||
|
|
||||||
|
const enabled = (
|
||||||
|
DirectoryCustomisations.requireCanonicalAliasAccessToPublish?.() === false ||
|
||||||
|
this.props.canSetCanonicalAlias
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LabelledToggleSwitch value={this.state.isRoomPublished}
|
<LabelledToggleSwitch value={this.state.isRoomPublished}
|
||||||
onChange={this.onRoomPublishChange}
|
onChange={this.onRoomPublishChange}
|
||||||
disabled={!this.props.canSetCanonicalAlias}
|
disabled={!enabled}
|
||||||
label={_t("Publish this room to the public in %(domain)s's room directory?", {
|
label={_t("Publish this room to the public in %(domain)s's room directory?", {
|
||||||
domain: client.getDomain(),
|
domain: client.getDomain(),
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -14,43 +14,57 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React, { useContext, useEffect } from "react";
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
import { IPreviewUrlResponse } from "matrix-js-sdk/src/client";
|
||||||
|
|
||||||
import { useStateToggle } from "../../../hooks/useStateToggle";
|
import { useStateToggle } from "../../../hooks/useStateToggle";
|
||||||
import LinkPreviewWidget from "./LinkPreviewWidget";
|
import LinkPreviewWidget from "./LinkPreviewWidget";
|
||||||
import AccessibleButton from "../elements/AccessibleButton";
|
import AccessibleButton from "../elements/AccessibleButton";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||||
|
|
||||||
const INITIAL_NUM_PREVIEWS = 2;
|
const INITIAL_NUM_PREVIEWS = 2;
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
links: string[]; // the URLs to be previewed
|
links: string[]; // the URLs to be previewed
|
||||||
mxEvent: MatrixEvent; // the Event associated with the preview
|
mxEvent: MatrixEvent; // the Event associated with the preview
|
||||||
onCancelClick?(): void; // called when the preview's cancel ('hide') button is clicked
|
onCancelClick(): void; // called when the preview's cancel ('hide') button is clicked
|
||||||
onHeightChanged?(): void; // called when the preview's contents has loaded
|
onHeightChanged(): void; // called when the preview's contents has loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onHeightChanged }) => {
|
const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onHeightChanged }) => {
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
const [expanded, toggleExpanded] = useStateToggle();
|
const [expanded, toggleExpanded] = useStateToggle();
|
||||||
|
|
||||||
|
const ts = mxEvent.getTs();
|
||||||
|
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => {
|
||||||
|
return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(link => {
|
||||||
|
return cli.getUrlPreview(link, ts).then(preview => [link, preview], error => {
|
||||||
|
console.error("Failed to get URL preview: " + error);
|
||||||
|
});
|
||||||
|
})).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
|
||||||
|
}, [links, ts], []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onHeightChanged();
|
onHeightChanged();
|
||||||
}, [onHeightChanged, expanded]);
|
}, [onHeightChanged, expanded, previews]);
|
||||||
|
|
||||||
const shownLinks = expanded ? links : links.slice(0, INITIAL_NUM_PREVIEWS);
|
const showPreviews = expanded ? previews : previews.slice(0, INITIAL_NUM_PREVIEWS);
|
||||||
|
|
||||||
let toggleButton;
|
let toggleButton: JSX.Element;
|
||||||
if (links.length > INITIAL_NUM_PREVIEWS) {
|
if (previews.length > INITIAL_NUM_PREVIEWS) {
|
||||||
toggleButton = <AccessibleButton onClick={toggleExpanded}>
|
toggleButton = <AccessibleButton onClick={toggleExpanded}>
|
||||||
{ expanded
|
{ expanded
|
||||||
? _t("Collapse")
|
? _t("Collapse")
|
||||||
: _t("Show %(count)s other previews", { count: links.length - shownLinks.length }) }
|
: _t("Show %(count)s other previews", { count: previews.length - showPreviews.length }) }
|
||||||
</AccessibleButton>;
|
</AccessibleButton>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="mx_LinkPreviewGroup">
|
return <div className="mx_LinkPreviewGroup">
|
||||||
{ shownLinks.map((link, i) => (
|
{ showPreviews.map(([link, preview], i) => (
|
||||||
<LinkPreviewWidget key={link} link={link} mxEvent={mxEvent} onHeightChanged={onHeightChanged}>
|
<LinkPreviewWidget key={link} link={link} preview={preview} mxEvent={mxEvent}>
|
||||||
{ i === 0 ? (
|
{ i === 0 ? (
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className="mx_LinkPreviewGroup_hide"
|
className="mx_LinkPreviewGroup_hide"
|
||||||
|
|
|
@ -21,7 +21,6 @@ import { IPreviewUrlResponse } from 'matrix-js-sdk/src/client';
|
||||||
|
|
||||||
import { linkifyElement } from '../../../HtmlUtils';
|
import { linkifyElement } from '../../../HtmlUtils';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import * as ImageUtils from "../../../ImageUtils";
|
import * as ImageUtils from "../../../ImageUtils";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
@ -29,37 +28,15 @@ import { mediaFromMxc } from "../../../customisations/Media";
|
||||||
import ImageView from '../elements/ImageView';
|
import ImageView from '../elements/ImageView';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
link: string; // the URL being previewed
|
link: string;
|
||||||
|
preview: IPreviewUrlResponse;
|
||||||
mxEvent: MatrixEvent; // the Event associated with the preview
|
mxEvent: MatrixEvent; // the Event associated with the preview
|
||||||
onHeightChanged(): void; // called when the preview's contents has loaded
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IState {
|
|
||||||
preview?: IPreviewUrlResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.rooms.LinkPreviewWidget")
|
@replaceableComponent("views.rooms.LinkPreviewWidget")
|
||||||
export default class LinkPreviewWidget extends React.Component<IProps, IState> {
|
export default class LinkPreviewWidget extends React.Component<IProps> {
|
||||||
private unmounted = false;
|
|
||||||
private readonly description = createRef<HTMLDivElement>();
|
private readonly description = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
preview: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((preview) => {
|
|
||||||
if (this.unmounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setState({ preview }, this.props.onHeightChanged);
|
|
||||||
}, (error) => {
|
|
||||||
console.error("Failed to get URL preview: " + error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (this.description.current) {
|
if (this.description.current) {
|
||||||
linkifyElement(this.description.current);
|
linkifyElement(this.description.current);
|
||||||
|
@ -72,12 +49,8 @@ export default class LinkPreviewWidget extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.unmounted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private onImageClick = ev => {
|
private onImageClick = ev => {
|
||||||
const p = this.state.preview;
|
const p = this.props.preview;
|
||||||
if (ev.button != 0 || ev.metaKey) return;
|
if (ev.button != 0 || ev.metaKey) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
|
@ -99,7 +72,7 @@ export default class LinkPreviewWidget extends React.Component<IProps, IState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const p = this.state.preview;
|
const p = this.props.preview;
|
||||||
if (!p || Object.keys(p).length === 0) {
|
if (!p || Object.keys(p).length === 0) {
|
||||||
return <div />;
|
return <div />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2017 New Vector Ltd.
|
Copyright 2017-2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -21,9 +21,10 @@ import { linkifyElement } from '../../../HtmlUtils';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import { mediaFromMxc } from "../../../customisations/Media";
|
import { mediaFromMxc } from "../../../customisations/Media";
|
||||||
|
import { getDisplayAliasForAliasSet } from '../../../Rooms';
|
||||||
|
|
||||||
export function getDisplayAliasForRoom(room) {
|
export function getDisplayAliasForRoom(room) {
|
||||||
return room.canonicalAlias || (room.aliases ? room.aliases[0] : "");
|
return getDisplayAliasForAliasSet(room.canonicalAlias, room.aliases);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const roomShape = PropTypes.shape({
|
export const roomShape = PropTypes.shape({
|
||||||
|
|
|
@ -95,7 +95,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
duration: Math.round(this.state.recorder.durationSeconds * 1000),
|
duration: Math.round(this.state.recorder.durationSeconds * 1000),
|
||||||
|
|
||||||
// https://github.com/matrix-org/matrix-doc/pull/3246
|
// https://github.com/matrix-org/matrix-doc/pull/3246
|
||||||
waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)),
|
waveform: this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)),
|
||||||
},
|
},
|
||||||
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
|
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
|
||||||
});
|
});
|
||||||
|
|
|
@ -280,6 +280,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
||||||
const mutedUsers = [];
|
const mutedUsers = [];
|
||||||
|
|
||||||
Object.keys(userLevels).forEach((user) => {
|
Object.keys(userLevels).forEach((user) => {
|
||||||
|
if (!Number.isInteger(userLevels[user])) { return; }
|
||||||
const canChange = userLevels[user] < currentUserLevel && canChangeLevels;
|
const canChange = userLevels[user] < currentUserLevel && canChangeLevels;
|
||||||
if (userLevels[user] > defaultUserLevel) { // privileged
|
if (userLevels[user] > defaultUserLevel) { // privileged
|
||||||
privilegedUsers.push(
|
privilegedUsers.push(
|
||||||
|
|
31
src/customisations/Alias.ts
Normal file
31
src/customisations/Alias.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string {
|
||||||
|
// E.g. prefer one of the aliases over another
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This interface summarises all available customisation points and also marks
|
||||||
|
// them all as optional. This allows customisers to only define and export the
|
||||||
|
// customisations they need while still maintaining type safety.
|
||||||
|
export interface IAliasCustomisations {
|
||||||
|
getDisplayAliasForAliasSet?: typeof getDisplayAliasForAliasSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real customisation module will define and export one or more of the
|
||||||
|
// customisation points that make up `IAliasCustomisations`.
|
||||||
|
export default {} as IAliasCustomisations;
|
31
src/customisations/Directory.ts
Normal file
31
src/customisations/Directory.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function requireCanonicalAliasAccessToPublish(): boolean {
|
||||||
|
// Some environments may not care about this requirement and could return false
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This interface summarises all available customisation points and also marks
|
||||||
|
// them all as optional. This allows customisers to only define and export the
|
||||||
|
// customisations they need while still maintaining type safety.
|
||||||
|
export interface IDirectoryCustomisations {
|
||||||
|
requireCanonicalAliasAccessToPublish?: typeof requireCanonicalAliasAccessToPublish;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real customisation module will define and export one or more of the
|
||||||
|
// customisation points that make up `IDirectoryCustomisations`.
|
||||||
|
export default {} as IDirectoryCustomisations;
|
|
@ -695,6 +695,7 @@
|
||||||
"Error leaving room": "Error leaving room",
|
"Error leaving room": "Error leaving room",
|
||||||
"Unrecognised address": "Unrecognised address",
|
"Unrecognised address": "Unrecognised address",
|
||||||
"You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.",
|
"You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.",
|
||||||
|
"User %(userId)s is already invited to the room": "User %(userId)s is already invited to the room",
|
||||||
"User %(userId)s is already in the room": "User %(userId)s is already in the room",
|
"User %(userId)s is already in the room": "User %(userId)s is already in the room",
|
||||||
"User %(user_id)s does not exist": "User %(user_id)s does not exist",
|
"User %(user_id)s does not exist": "User %(user_id)s does not exist",
|
||||||
"User %(user_id)s may or may not exist": "User %(user_id)s may or may not exist",
|
"User %(user_id)s may or may not exist": "User %(user_id)s may or may not exist",
|
||||||
|
|
|
@ -22,7 +22,6 @@ import { RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS } from "./RightPanelStoreP
|
||||||
import { ActionPayload } from "../dispatcher/payloads";
|
import { ActionPayload } from "../dispatcher/payloads";
|
||||||
import { Action } from '../dispatcher/actions';
|
import { Action } from '../dispatcher/actions';
|
||||||
import { SettingLevel } from "../settings/SettingLevel";
|
import { SettingLevel } from "../settings/SettingLevel";
|
||||||
import RoomViewStore from './RoomViewStore';
|
|
||||||
|
|
||||||
interface RightPanelStoreState {
|
interface RightPanelStoreState {
|
||||||
// Whether or not to show the right panel at all. We split out rooms and groups
|
// Whether or not to show the right panel at all. We split out rooms and groups
|
||||||
|
@ -68,6 +67,7 @@ const MEMBER_INFO_PHASES = [
|
||||||
export default class RightPanelStore extends Store<ActionPayload> {
|
export default class RightPanelStore extends Store<ActionPayload> {
|
||||||
private static instance: RightPanelStore;
|
private static instance: RightPanelStore;
|
||||||
private state: RightPanelStoreState;
|
private state: RightPanelStoreState;
|
||||||
|
private lastRoomId: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(dis);
|
super(dis);
|
||||||
|
@ -147,8 +147,10 @@ export default class RightPanelStore extends Store<ActionPayload> {
|
||||||
__onDispatch(payload: ActionPayload) {
|
__onDispatch(payload: ActionPayload) {
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'view_room':
|
case 'view_room':
|
||||||
|
if (payload.room_id === this.lastRoomId) break; // skip this transition, probably a permalink
|
||||||
|
// fallthrough
|
||||||
case 'view_group':
|
case 'view_group':
|
||||||
if (payload.room_id === RoomViewStore.getRoomId()) break; // skip this transition, probably a permalink
|
this.lastRoomId = payload.room_id;
|
||||||
|
|
||||||
// Reset to the member list if we're viewing member info
|
// Reset to the member list if we're viewing member info
|
||||||
if (MEMBER_INFO_PHASES.includes(this.state.lastRoomPhase)) {
|
if (MEMBER_INFO_PHASES.includes(this.state.lastRoomPhase)) {
|
||||||
|
|
54
src/utils/FixedRollingArray.ts
Normal file
54
src/utils/FixedRollingArray.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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 { arrayFastClone, arraySeed } from "./arrays";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An array which is of fixed length and accepts rolling values. Values will
|
||||||
|
* be inserted on the left, falling off the right.
|
||||||
|
*/
|
||||||
|
export class FixedRollingArray<T> {
|
||||||
|
private samples: T[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new fixed rolling array.
|
||||||
|
* @param width The width of the array.
|
||||||
|
* @param padValue The value to seed the array with.
|
||||||
|
*/
|
||||||
|
constructor(private width: number, padValue: T) {
|
||||||
|
this.samples = arraySeed(padValue, this.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The array, as a fixed length.
|
||||||
|
*/
|
||||||
|
public get value(): T[] {
|
||||||
|
return this.samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes a value to the array.
|
||||||
|
* @param value The value to push.
|
||||||
|
*/
|
||||||
|
public pushValue(value: T) {
|
||||||
|
let swap = arrayFastClone(this.samples);
|
||||||
|
swap.splice(0, 0, value);
|
||||||
|
if (swap.length > this.width) {
|
||||||
|
swap = swap.slice(0, this.width);
|
||||||
|
}
|
||||||
|
this.samples = swap;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -21,7 +21,7 @@ limitations under the License.
|
||||||
* MIT license
|
* MIT license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function safariVersionCheck(ua) {
|
function safariVersionCheck(ua: string): boolean {
|
||||||
console.log("Browser is Safari - checking version for COLR support");
|
console.log("Browser is Safari - checking version for COLR support");
|
||||||
try {
|
try {
|
||||||
const safariVersionMatch = ua.match(/Mac OS X ([\d|_]+).*Version\/([\d|.]+).*Safari/);
|
const safariVersionMatch = ua.match(/Mac OS X ([\d|_]+).*Version\/([\d|.]+).*Safari/);
|
||||||
|
@ -44,7 +44,7 @@ function safariVersionCheck(ua) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isColrFontSupported() {
|
async function isColrFontSupported(): Promise<boolean> {
|
||||||
console.log("Checking for COLR support");
|
console.log("Checking for COLR support");
|
||||||
|
|
||||||
const { userAgent } = navigator;
|
const { userAgent } = navigator;
|
||||||
|
@ -101,7 +101,7 @@ async function isColrFontSupported() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let colrFontCheckStarted = false;
|
let colrFontCheckStarted = false;
|
||||||
export async function fixupColorFonts() {
|
export async function fixupColorFonts(): Promise<void> {
|
||||||
if (colrFontCheckStarted) {
|
if (colrFontCheckStarted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -112,14 +112,14 @@ export async function fixupColorFonts() {
|
||||||
document.fonts.add(new FontFace("Twemoji", path, {}));
|
document.fonts.add(new FontFace("Twemoji", path, {}));
|
||||||
// For at least Chrome on Windows 10, we have to explictly add extra
|
// For at least Chrome on Windows 10, we have to explictly add extra
|
||||||
// weights for the emoji to appear in bold messages, etc.
|
// weights for the emoji to appear in bold messages, etc.
|
||||||
document.fonts.add(new FontFace("Twemoji", path, { weight: 600 }));
|
document.fonts.add(new FontFace("Twemoji", path, { weight: "600" }));
|
||||||
document.fonts.add(new FontFace("Twemoji", path, { weight: 700 }));
|
document.fonts.add(new FontFace("Twemoji", path, { weight: "700" }));
|
||||||
} else {
|
} else {
|
||||||
// fall back to SBIX, generated via https://github.com/matrix-org/twemoji-colr/tree/matthew/sbix
|
// fall back to SBIX, generated via https://github.com/matrix-org/twemoji-colr/tree/matthew/sbix
|
||||||
const path = `url('${require("../../res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2")}')`;
|
const path = `url('${require("../../res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2")}')`;
|
||||||
document.fonts.add(new FontFace("Twemoji", path, {}));
|
document.fonts.add(new FontFace("Twemoji", path, {}));
|
||||||
document.fonts.add(new FontFace("Twemoji", path, { weight: 600 }));
|
document.fonts.add(new FontFace("Twemoji", path, { weight: "600" }));
|
||||||
document.fonts.add(new FontFace("Twemoji", path, { weight: 700 }));
|
document.fonts.add(new FontFace("Twemoji", path, { weight: "700" }));
|
||||||
}
|
}
|
||||||
// ...and if SBIX is not supported, the browser will fall back to one of the native fonts specified.
|
// ...and if SBIX is not supported, the browser will fall back to one of the native fonts specified.
|
||||||
}
|
}
|
|
@ -39,6 +39,9 @@ const UNKNOWN_PROFILE_ERRORS = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UN
|
||||||
|
|
||||||
export type CompletionStates = Record<string, InviteState>;
|
export type CompletionStates = Record<string, InviteState>;
|
||||||
|
|
||||||
|
const USER_ALREADY_JOINED = "IO.ELEMENT.ALREADY_JOINED";
|
||||||
|
const USER_ALREADY_INVITED = "IO.ELEMENT.ALREADY_INVITED";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invites multiple addresses to a room or group, handling rate limiting from the server
|
* Invites multiple addresses to a room or group, handling rate limiting from the server
|
||||||
*/
|
*/
|
||||||
|
@ -130,9 +133,14 @@ export default class MultiInviter {
|
||||||
if (!room) throw new Error("Room not found");
|
if (!room) throw new Error("Room not found");
|
||||||
|
|
||||||
const member = room.getMember(addr);
|
const member = room.getMember(addr);
|
||||||
if (member && ['join', 'invite'].includes(member.membership)) {
|
if (member?.membership === "join") {
|
||||||
throw new new MatrixError({
|
throw new MatrixError({
|
||||||
errcode: "RIOT.ALREADY_IN_ROOM",
|
errcode: USER_ALREADY_JOINED,
|
||||||
|
error: "Member already joined",
|
||||||
|
});
|
||||||
|
} else if (member?.membership === "invite") {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: USER_ALREADY_INVITED,
|
||||||
error: "Member already invited",
|
error: "Member already invited",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -180,30 +188,47 @@ export default class MultiInviter {
|
||||||
|
|
||||||
let errorText;
|
let errorText;
|
||||||
let fatal = false;
|
let fatal = false;
|
||||||
if (err.errcode === 'M_FORBIDDEN') {
|
switch (err.errcode) {
|
||||||
fatal = true;
|
case "M_FORBIDDEN":
|
||||||
errorText = _t('You do not have permission to invite people to this room.');
|
errorText = _t('You do not have permission to invite people to this room.');
|
||||||
} else if (err.errcode === "RIOT.ALREADY_IN_ROOM") {
|
fatal = true;
|
||||||
errorText = _t("User %(userId)s is already in the room", { userId: address });
|
break;
|
||||||
} else if (err.errcode === 'M_LIMIT_EXCEEDED') {
|
case USER_ALREADY_INVITED:
|
||||||
// we're being throttled so wait a bit & try again
|
errorText = _t("User %(userId)s is already invited to the room", { userId: address });
|
||||||
setTimeout(() => {
|
break;
|
||||||
this.doInvite(address, ignoreProfile).then(resolve, reject);
|
case USER_ALREADY_JOINED:
|
||||||
}, 5000);
|
errorText = _t("User %(userId)s is already in the room", { userId: address });
|
||||||
return;
|
break;
|
||||||
} else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND'].includes(err.errcode)) {
|
case "M_LIMIT_EXCEEDED":
|
||||||
errorText = _t("User %(user_id)s does not exist", { user_id: address });
|
// we're being throttled so wait a bit & try again
|
||||||
} else if (err.errcode === 'M_PROFILE_UNDISCLOSED') {
|
setTimeout(() => {
|
||||||
errorText = _t("User %(user_id)s may or may not exist", { user_id: address });
|
this.doInvite(address, ignoreProfile).then(resolve, reject);
|
||||||
} else if (err.errcode === 'M_PROFILE_NOT_FOUND' && !ignoreProfile) {
|
}, 5000);
|
||||||
// Invite without the profile check
|
return;
|
||||||
console.warn(`User ${address} does not have a profile - inviting anyways automatically`);
|
case "M_NOT_FOUND":
|
||||||
this.doInvite(address, true).then(resolve, reject);
|
case "M_USER_NOT_FOUND":
|
||||||
} else if (err.errcode === "M_BAD_STATE") {
|
errorText = _t("User %(user_id)s does not exist", { user_id: address });
|
||||||
errorText = _t("The user must be unbanned before they can be invited.");
|
break;
|
||||||
} else if (err.errcode === "M_UNSUPPORTED_ROOM_VERSION") {
|
case "M_PROFILE_UNDISCLOSED":
|
||||||
errorText = _t("The user's homeserver does not support the version of the room.");
|
errorText = _t("User %(user_id)s may or may not exist", { user_id: address });
|
||||||
} else {
|
break;
|
||||||
|
case "M_PROFILE_NOT_FOUND":
|
||||||
|
if (!ignoreProfile) {
|
||||||
|
// Invite without the profile check
|
||||||
|
console.warn(`User ${address} does not have a profile - inviting anyways automatically`);
|
||||||
|
this.doInvite(address, true).then(resolve, reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "M_BAD_STATE":
|
||||||
|
errorText = _t("The user must be unbanned before they can be invited.");
|
||||||
|
break;
|
||||||
|
case "M_UNSUPPORTED_ROOM_VERSION":
|
||||||
|
errorText = _t("The user's homeserver does not support the version of the room.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errorText) {
|
||||||
errorText = _t('Unknown server error');
|
errorText = _t('Unknown server error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -112,11 +112,9 @@ export function arrayRescale(input: number[], newMin: number, newMax: number): n
|
||||||
* @returns {T[]} The array.
|
* @returns {T[]} The array.
|
||||||
*/
|
*/
|
||||||
export function arraySeed<T>(val: T, length: number): T[] {
|
export function arraySeed<T>(val: T, length: number): T[] {
|
||||||
const a: T[] = [];
|
// Size the array up front for performance, and use `fill` to let the browser
|
||||||
for (let i = 0; i < length; i++) {
|
// optimize the operation better than we can with a `for` loop, if it wants.
|
||||||
a.push(val);
|
return new Array<T>(length).fill(val);
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -31,6 +31,7 @@ export enum PlaybackState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PLAYBACK_WAVEFORM_SAMPLES = 39;
|
export const PLAYBACK_WAVEFORM_SAMPLES = 39;
|
||||||
|
const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120]
|
||||||
const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
|
const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
|
||||||
|
|
||||||
function makePlaybackWaveform(input: number[]): number[] {
|
function makePlaybackWaveform(input: number[]): number[] {
|
||||||
|
@ -51,6 +52,12 @@ function makePlaybackWaveform(input: number[]): number[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Playback extends EventEmitter implements IDestroyable {
|
export class Playback extends EventEmitter implements IDestroyable {
|
||||||
|
/**
|
||||||
|
* Stable waveform for representing a thumbnail of the media. Values are
|
||||||
|
* guaranteed to be between zero and one, inclusive.
|
||||||
|
*/
|
||||||
|
public readonly thumbnailWaveform: number[];
|
||||||
|
|
||||||
private readonly context: AudioContext;
|
private readonly context: AudioContext;
|
||||||
private source: AudioBufferSourceNode;
|
private source: AudioBufferSourceNode;
|
||||||
private state = PlaybackState.Decoding;
|
private state = PlaybackState.Decoding;
|
||||||
|
@ -72,6 +79,7 @@ export class Playback extends EventEmitter implements IDestroyable {
|
||||||
this.fileSize = this.buf.byteLength;
|
this.fileSize = this.buf.byteLength;
|
||||||
this.context = createAudioContext();
|
this.context = createAudioContext();
|
||||||
this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES);
|
this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES);
|
||||||
|
this.thumbnailWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, THUMBNAIL_WAVEFORM_SAMPLES);
|
||||||
this.waveformObservable.update(this.resampledWaveform);
|
this.waveformObservable.update(this.resampledWaveform);
|
||||||
this.clock = new PlaybackClock(this.context);
|
this.clock = new PlaybackClock(this.context);
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,14 +22,29 @@ declare const currentTime: number;
|
||||||
// declare const currentFrame: number;
|
// declare const currentFrame: number;
|
||||||
// declare const sampleRate: number;
|
// declare const sampleRate: number;
|
||||||
|
|
||||||
|
// We rate limit here to avoid overloading downstream consumers with amplitude information.
|
||||||
|
// The two major consumers are the voice message waveform thumbnail (resampled down to an
|
||||||
|
// appropriate length) and the live waveform shown to the user. Effectively, this controls
|
||||||
|
// the refresh rate of that live waveform and the number of samples the thumbnail has to
|
||||||
|
// work with.
|
||||||
|
const TARGET_AMPLITUDE_FREQUENCY = 16; // Hz
|
||||||
|
|
||||||
|
function roundTimeToTargetFreq(seconds: number): number {
|
||||||
|
// Epsilon helps avoid floating point rounding issues (1 + 1 = 1.999999, etc)
|
||||||
|
return Math.round((seconds + Number.EPSILON) * TARGET_AMPLITUDE_FREQUENCY) / TARGET_AMPLITUDE_FREQUENCY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTimeForTargetFreq(roundedSeconds: number): number {
|
||||||
|
// The extra round is just to make sure we cut off any floating point issues
|
||||||
|
return roundTimeToTargetFreq(roundedSeconds + (1 / TARGET_AMPLITUDE_FREQUENCY));
|
||||||
|
}
|
||||||
|
|
||||||
class MxVoiceWorklet extends AudioWorkletProcessor {
|
class MxVoiceWorklet extends AudioWorkletProcessor {
|
||||||
private nextAmplitudeSecond = 0;
|
private nextAmplitudeSecond = 0;
|
||||||
|
private amplitudeIndex = 0;
|
||||||
|
|
||||||
process(inputs, outputs, parameters) {
|
process(inputs, outputs, parameters) {
|
||||||
// We only fire amplitude updates once a second to avoid flooding the recording instance
|
const currentSecond = roundTimeToTargetFreq(currentTime);
|
||||||
// with useless data. Much of the data would end up discarded, so we ratelimit ourselves
|
|
||||||
// here.
|
|
||||||
const currentSecond = Math.round(currentTime);
|
|
||||||
if (currentSecond === this.nextAmplitudeSecond) {
|
if (currentSecond === this.nextAmplitudeSecond) {
|
||||||
// We're expecting exactly one mono input source, so just grab the very first frame of
|
// We're expecting exactly one mono input source, so just grab the very first frame of
|
||||||
// samples for the analysis.
|
// samples for the analysis.
|
||||||
|
@ -47,9 +62,9 @@ class MxVoiceWorklet extends AudioWorkletProcessor {
|
||||||
this.port.postMessage(<IAmplitudePayload>{
|
this.port.postMessage(<IAmplitudePayload>{
|
||||||
ev: PayloadEvent.AmplitudeMark,
|
ev: PayloadEvent.AmplitudeMark,
|
||||||
amplitude: amplitude,
|
amplitude: amplitude,
|
||||||
forSecond: currentSecond,
|
forIndex: this.amplitudeIndex++,
|
||||||
});
|
});
|
||||||
this.nextAmplitudeSecond++;
|
this.nextAmplitudeSecond = nextTimeForTargetFreq(currentSecond);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We mostly use this worklet to fire regular clock updates through to components
|
// We mostly use this worklet to fire regular clock updates through to components
|
||||||
|
|
|
@ -19,7 +19,6 @@ import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import MediaDeviceHandler from "../MediaDeviceHandler";
|
import MediaDeviceHandler from "../MediaDeviceHandler";
|
||||||
import { SimpleObservable } from "matrix-widget-api";
|
import { SimpleObservable } from "matrix-widget-api";
|
||||||
import { clamp, percentageOf, percentageWithin } from "../utils/numbers";
|
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import { IDestroyable } from "../utils/IDestroyable";
|
import { IDestroyable } from "../utils/IDestroyable";
|
||||||
import { Singleflight } from "../utils/Singleflight";
|
import { Singleflight } from "../utils/Singleflight";
|
||||||
|
@ -29,6 +28,8 @@ import { Playback } from "./Playback";
|
||||||
import { createAudioContext } from "./compat";
|
import { createAudioContext } from "./compat";
|
||||||
import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
|
import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
|
||||||
import { uploadFile } from "../ContentMessages";
|
import { uploadFile } from "../ContentMessages";
|
||||||
|
import { FixedRollingArray } from "../utils/FixedRollingArray";
|
||||||
|
import { clamp } from "../utils/numbers";
|
||||||
|
|
||||||
const CHANNELS = 1; // stereo isn't important
|
const CHANNELS = 1; // stereo isn't important
|
||||||
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
||||||
|
@ -61,7 +62,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
private recorderContext: AudioContext;
|
private recorderContext: AudioContext;
|
||||||
private recorderSource: MediaStreamAudioSourceNode;
|
private recorderSource: MediaStreamAudioSourceNode;
|
||||||
private recorderStream: MediaStream;
|
private recorderStream: MediaStream;
|
||||||
private recorderFFT: AnalyserNode;
|
|
||||||
private recorderWorklet: AudioWorkletNode;
|
private recorderWorklet: AudioWorkletNode;
|
||||||
private recorderProcessor: ScriptProcessorNode;
|
private recorderProcessor: ScriptProcessorNode;
|
||||||
private buffer = new Uint8Array(0); // use this.audioBuffer to access
|
private buffer = new Uint8Array(0); // use this.audioBuffer to access
|
||||||
|
@ -70,6 +70,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
private observable: SimpleObservable<IRecordingUpdate>;
|
private observable: SimpleObservable<IRecordingUpdate>;
|
||||||
private amplitudes: number[] = []; // at each second mark, generated
|
private amplitudes: number[] = []; // at each second mark, generated
|
||||||
private playback: Playback;
|
private playback: Playback;
|
||||||
|
private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0);
|
||||||
|
|
||||||
public constructor(private client: MatrixClient) {
|
public constructor(private client: MatrixClient) {
|
||||||
super();
|
super();
|
||||||
|
@ -111,14 +112,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
// latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing)
|
// latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing)
|
||||||
});
|
});
|
||||||
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
|
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
|
||||||
this.recorderFFT = this.recorderContext.createAnalyser();
|
|
||||||
|
|
||||||
// Bring the FFT time domain down a bit. The default is 2048, and this must be a power
|
|
||||||
// of two. We use 64 points because we happen to know down the line we need less than
|
|
||||||
// that, but 32 would be too few. Large numbers are not helpful here and do not add
|
|
||||||
// precision: they introduce higher precision outputs of the FFT (frequency data), but
|
|
||||||
// it makes the time domain less than helpful.
|
|
||||||
this.recorderFFT.fftSize = 64;
|
|
||||||
|
|
||||||
// Set up our worklet. We use this for timing information and waveform analysis: the
|
// Set up our worklet. We use this for timing information and waveform analysis: the
|
||||||
// web audio API prefers this be done async to avoid holding the main thread with math.
|
// web audio API prefers this be done async to avoid holding the main thread with math.
|
||||||
|
@ -129,8 +122,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect our inputs and outputs
|
// Connect our inputs and outputs
|
||||||
this.recorderSource.connect(this.recorderFFT);
|
|
||||||
|
|
||||||
if (this.recorderContext.audioWorklet) {
|
if (this.recorderContext.audioWorklet) {
|
||||||
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
|
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
|
||||||
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
|
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
|
||||||
|
@ -145,8 +136,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
break;
|
break;
|
||||||
case PayloadEvent.AmplitudeMark:
|
case PayloadEvent.AmplitudeMark:
|
||||||
// Sanity check to make sure we're adding about one sample per second
|
// Sanity check to make sure we're adding about one sample per second
|
||||||
if (ev.data['forSecond'] === this.amplitudes.length) {
|
if (ev.data['forIndex'] === this.amplitudes.length) {
|
||||||
this.amplitudes.push(ev.data['amplitude']);
|
this.amplitudes.push(ev.data['amplitude']);
|
||||||
|
this.liveWaveform.pushValue(ev.data['amplitude']);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -231,36 +223,8 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
private processAudioUpdate = (timeSeconds: number) => {
|
private processAudioUpdate = (timeSeconds: number) => {
|
||||||
if (!this.recording) return;
|
if (!this.recording) return;
|
||||||
|
|
||||||
// The time domain is the input to the FFT, which means we use an array of the same
|
|
||||||
// size. The time domain is also known as the audio waveform. We're ignoring the
|
|
||||||
// output of the FFT here (frequency data) because we're not interested in it.
|
|
||||||
const data = new Float32Array(this.recorderFFT.fftSize);
|
|
||||||
if (!this.recorderFFT.getFloatTimeDomainData) {
|
|
||||||
// Safari compat
|
|
||||||
const data2 = new Uint8Array(this.recorderFFT.fftSize);
|
|
||||||
this.recorderFFT.getByteTimeDomainData(data2);
|
|
||||||
for (let i = 0; i < data2.length; i++) {
|
|
||||||
data[i] = percentageWithin(percentageOf(data2[i], 0, 256), -1, 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.recorderFFT.getFloatTimeDomainData(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can't just `Array.from()` the array because we're dealing with 32bit floats
|
|
||||||
// and the built-in function won't consider that when converting between numbers.
|
|
||||||
// However, the runtime will convert the float32 to a float64 during the math operations
|
|
||||||
// which is why the loop works below. Note that a `.map()` call also doesn't work
|
|
||||||
// and will instead return a Float32Array still.
|
|
||||||
const translatedData: number[] = [];
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
// We're clamping the values so we can do that math operation mentioned above,
|
|
||||||
// and to ensure that we produce consistent data (it's possible for the array
|
|
||||||
// to exceed the specified range with some audio input devices).
|
|
||||||
translatedData.push(clamp(data[i], 0, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.observable.update({
|
this.observable.update({
|
||||||
waveform: translatedData,
|
waveform: this.liveWaveform.value.map(v => clamp(v, 0, 1)),
|
||||||
timeSeconds: timeSeconds,
|
timeSeconds: timeSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,6 @@ export interface ITimingPayload extends IPayload {
|
||||||
|
|
||||||
export interface IAmplitudePayload extends IPayload {
|
export interface IAmplitudePayload extends IPayload {
|
||||||
ev: PayloadEvent.AmplitudeMark;
|
ev: PayloadEvent.AmplitudeMark;
|
||||||
forSecond: number;
|
forIndex: number;
|
||||||
amplitude: number;
|
amplitude: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,8 +22,10 @@ import sdk from "../../../skinned-sdk";
|
||||||
import { mkEvent, mkStubRoom } from "../../../test-utils";
|
import { mkEvent, mkStubRoom } from "../../../test-utils";
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||||
import * as languageHandler from "../../../../src/languageHandler";
|
import * as languageHandler from "../../../../src/languageHandler";
|
||||||
|
import * as TestUtils from "../../../test-utils";
|
||||||
|
|
||||||
const TextualBody = sdk.getComponent("views.messages.TextualBody");
|
const _TextualBody = sdk.getComponent("views.messages.TextualBody");
|
||||||
|
const TextualBody = TestUtils.wrapInMatrixClientContext(_TextualBody);
|
||||||
|
|
||||||
configure({ adapter: new Adapter() });
|
configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
|
@ -305,10 +307,9 @@ describe("<TextualBody />", () => {
|
||||||
const wrapper = mount(<TextualBody mxEvent={ev} showUrlPreview={true} onHeightChanged={() => {}} />);
|
const wrapper = mount(<TextualBody mxEvent={ev} showUrlPreview={true} onHeightChanged={() => {}} />);
|
||||||
expect(wrapper.text()).toBe(ev.getContent().body);
|
expect(wrapper.text()).toBe(ev.getContent().body);
|
||||||
|
|
||||||
let widgets = wrapper.find("LinkPreviewWidget");
|
let widgets = wrapper.find("LinkPreviewGroup");
|
||||||
// at this point we should have exactly one widget
|
// at this point we should have exactly one link
|
||||||
expect(widgets.length).toBe(1);
|
expect(widgets.at(0).prop("links")).toEqual(["https://matrix.org/"]);
|
||||||
expect(widgets.at(0).prop("link")).toBe("https://matrix.org/");
|
|
||||||
|
|
||||||
// simulate an event edit and check the transition from the old URL preview to the new one
|
// simulate an event edit and check the transition from the old URL preview to the new one
|
||||||
const ev2 = mkEvent({
|
const ev2 = mkEvent({
|
||||||
|
@ -333,11 +334,9 @@ describe("<TextualBody />", () => {
|
||||||
|
|
||||||
// XXX: this is to give TextualBody enough time for state to settle
|
// XXX: this is to give TextualBody enough time for state to settle
|
||||||
wrapper.setState({}, () => {
|
wrapper.setState({}, () => {
|
||||||
widgets = wrapper.find("LinkPreviewWidget");
|
widgets = wrapper.find("LinkPreviewGroup");
|
||||||
// at this point we should have exactly two widgets (not the matrix.org one anymore)
|
// at this point we should have exactly two links (not the matrix.org one anymore)
|
||||||
expect(widgets.length).toBe(2);
|
expect(widgets.at(0).prop("links")).toEqual(["https://vector.im/", "https://riot.im/"]);
|
||||||
expect(widgets.at(0).prop("link")).toBe("https://vector.im/");
|
|
||||||
expect(widgets.at(1).prop("link")).toBe("https://riot.im/");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
65
test/utils/FixedRollingArray-test.ts
Normal file
65
test/utils/FixedRollingArray-test.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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 { FixedRollingArray } from "../../src/utils/FixedRollingArray";
|
||||||
|
|
||||||
|
describe('FixedRollingArray', () => {
|
||||||
|
it('should seed the array with the given value', () => {
|
||||||
|
const seed = "test";
|
||||||
|
const width = 24;
|
||||||
|
const array = new FixedRollingArray(width, seed);
|
||||||
|
|
||||||
|
expect(array.value.length).toBe(width);
|
||||||
|
expect(array.value.every(v => v === seed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert at the correct end', () => {
|
||||||
|
const seed = "test";
|
||||||
|
const value = "changed";
|
||||||
|
const width = 24;
|
||||||
|
const array = new FixedRollingArray(width, seed);
|
||||||
|
array.pushValue(value);
|
||||||
|
|
||||||
|
expect(array.value.length).toBe(width);
|
||||||
|
expect(array.value[0]).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should roll over', () => {
|
||||||
|
const seed = -1;
|
||||||
|
const width = 24;
|
||||||
|
const array = new FixedRollingArray(width, seed);
|
||||||
|
|
||||||
|
const maxValue = width * 2;
|
||||||
|
const minValue = width; // because we're forcing a rollover
|
||||||
|
for (let i = 0; i <= maxValue; i++) {
|
||||||
|
array.pushValue(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(array.value.length).toBe(width);
|
||||||
|
|
||||||
|
for (let i = 1; i < width; i++) {
|
||||||
|
const current = array.value[i];
|
||||||
|
const previous = array.value[i - 1];
|
||||||
|
expect(previous - current).toBe(1);
|
||||||
|
|
||||||
|
if (i === 1) {
|
||||||
|
expect(previous).toBe(maxValue);
|
||||||
|
} else if (i === width) {
|
||||||
|
expect(current).toBe(minValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -1488,6 +1488,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8"
|
resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8"
|
||||||
integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ==
|
integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ==
|
||||||
|
|
||||||
|
"@types/css-font-loading-module@^0.0.6":
|
||||||
|
version "0.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.6.tgz#1ac3417ed31eeb953134d29b56bca921644b87c0"
|
||||||
|
integrity sha512-MBvSMSxXFtIukyXRU3HhzL369rIWaqMVQD5kmDCYIFFD6Fe3lJ4c9UnLD02MLdTp7Z6ti7rO3SQtuDo7C80mmw==
|
||||||
|
|
||||||
"@types/diff-match-patch@^1.0.32":
|
"@types/diff-match-patch@^1.0.32":
|
||||||
version "1.0.32"
|
version "1.0.32"
|
||||||
resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz#d9c3b8c914aa8229485351db4865328337a3d09f"
|
resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz#d9c3b8c914aa8229485351db4865328337a3d09f"
|
||||||
|
|
Loading…
Reference in a new issue