Merge remote-tracking branch 'origin/develop' into dbkr/hold_ui

This commit is contained in:
David Baker 2020-12-08 11:48:14 +00:00
commit 4a009d480d
20 changed files with 674 additions and 43 deletions

View file

@ -1,3 +1,86 @@
Changes in [3.10.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.10.0) (2020-12-07)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.10.0-rc.1...v3.10.0)
* Upgrade to JS SDK 9.3.0
Changes in [3.10.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.10.0-rc.1) (2020-12-02)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.9.0...v3.10.0-rc.1)
* Upgrade to JS SDK 9.3.0-rc.1
* Translations update from Weblate
[\#5461](https://github.com/matrix-org/matrix-react-sdk/pull/5461)
* Fix VoIP call plinth on dark theme
[\#5460](https://github.com/matrix-org/matrix-react-sdk/pull/5460)
* Add sanity checking around widget pinning
[\#5459](https://github.com/matrix-org/matrix-react-sdk/pull/5459)
* Update i18n for Appearance User Settings
[\#5457](https://github.com/matrix-org/matrix-react-sdk/pull/5457)
* Only show 'answered elsewhere' if we tried to answer too
[\#5455](https://github.com/matrix-org/matrix-react-sdk/pull/5455)
* Fixed Avatar for 3PID invites
[\#5442](https://github.com/matrix-org/matrix-react-sdk/pull/5442)
* Slightly better error if we can't capture user media
[\#5449](https://github.com/matrix-org/matrix-react-sdk/pull/5449)
* Make it possible in-code to hide rooms from the room list
[\#5445](https://github.com/matrix-org/matrix-react-sdk/pull/5445)
* Fix the stickerpicker
[\#5447](https://github.com/matrix-org/matrix-react-sdk/pull/5447)
* Add live password validation to change password dialog
[\#5436](https://github.com/matrix-org/matrix-react-sdk/pull/5436)
* LaTeX rendering in element-web using KaTeX
[\#5244](https://github.com/matrix-org/matrix-react-sdk/pull/5244)
* Add lifecycle customisation point after logout
[\#5448](https://github.com/matrix-org/matrix-react-sdk/pull/5448)
* Simplify UserMenu for Guests as they can't use most of the options
[\#5421](https://github.com/matrix-org/matrix-react-sdk/pull/5421)
* Fix known issues with modal widgets
[\#5444](https://github.com/matrix-org/matrix-react-sdk/pull/5444)
* Fix existing widgets not having approved capabilities for their function
[\#5443](https://github.com/matrix-org/matrix-react-sdk/pull/5443)
* Use the WidgetDriver to run OIDC requests
[\#5440](https://github.com/matrix-org/matrix-react-sdk/pull/5440)
* Add a customisation point for widget permissions and fix amnesia issues
[\#5439](https://github.com/matrix-org/matrix-react-sdk/pull/5439)
* Fix Widget event notification text including spurious space
[\#5441](https://github.com/matrix-org/matrix-react-sdk/pull/5441)
* Move call listener out of MatrixChat
[\#5438](https://github.com/matrix-org/matrix-react-sdk/pull/5438)
* New Look in-Call View
[\#5432](https://github.com/matrix-org/matrix-react-sdk/pull/5432)
* Support arbitrary widgets sticking to the screen + sending stickers
[\#5435](https://github.com/matrix-org/matrix-react-sdk/pull/5435)
* Auth typescripting and validation tweaks
[\#5433](https://github.com/matrix-org/matrix-react-sdk/pull/5433)
* Add new widget API actions for changing rooms and sending/receiving events
[\#5385](https://github.com/matrix-org/matrix-react-sdk/pull/5385)
* Revert room header click behaviour to opening room settings
[\#5434](https://github.com/matrix-org/matrix-react-sdk/pull/5434)
* Add option to send/edit a message with Ctrl + Enter / Command + Enter
[\#5160](https://github.com/matrix-org/matrix-react-sdk/pull/5160)
* Add Analytics instrumentation to the Homepage
[\#5409](https://github.com/matrix-org/matrix-react-sdk/pull/5409)
* Fix encrypted video playback in Chrome-based browsers
[\#5430](https://github.com/matrix-org/matrix-react-sdk/pull/5430)
* Add border-radius for video
[\#5333](https://github.com/matrix-org/matrix-react-sdk/pull/5333)
* Push name to the end, near text, in IRC layout
[\#5166](https://github.com/matrix-org/matrix-react-sdk/pull/5166)
* Disable notifications for the room you have recently been active in
[\#5325](https://github.com/matrix-org/matrix-react-sdk/pull/5325)
* Search through the list of unfiltered rooms rather than the rooms in the
state which are already filtered by the search text
[\#5331](https://github.com/matrix-org/matrix-react-sdk/pull/5331)
* Lighten blockquote colour in dark mode
[\#5353](https://github.com/matrix-org/matrix-react-sdk/pull/5353)
* Specify community description img must be mxc urls
[\#5364](https://github.com/matrix-org/matrix-react-sdk/pull/5364)
* Add keyboard shortcut to close the current conversation
[\#5253](https://github.com/matrix-org/matrix-react-sdk/pull/5253)
* Redirect user home from auth screens if they are already logged in
[\#5423](https://github.com/matrix-org/matrix-react-sdk/pull/5423)
Changes in [3.9.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.9.0) (2020-11-23) Changes in [3.9.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.9.0) (2020-11-23)
=================================================================================================== ===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.9.0-rc.1...v3.9.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.9.0-rc.1...v3.9.0)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.9.0", "version": "3.10.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -58,6 +58,7 @@
"blueimp-canvas-to-blob": "^3.27.0", "blueimp-canvas-to-blob": "^3.27.0",
"browser-encrypt-attachment": "^0.3.0", "browser-encrypt-attachment": "^0.3.0",
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
"cheerio": "^1.0.0-rc.3",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"commonmark": "^0.29.1", "commonmark": "^0.29.1",
"counterpart": "^0.18.6", "counterpart": "^0.18.6",
@ -77,7 +78,6 @@
"html-entities": "^1.3.1", "html-entities": "^1.3.1",
"is-ip": "^2.0.0", "is-ip": "^2.0.0",
"katex": "^0.12.0", "katex": "^0.12.0",
"cheerio": "^1.0.0-rc.3",
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",

View file

@ -1 +0,0 @@
app-tests.sh

View file

@ -393,14 +393,14 @@ export default class CallHandler {
title = _t("Unable to access microphone"); title = _t("Unable to access microphone");
description = <div> description = <div>
{_t( {_t(
"Call failed because no microphone could not be accessed. " + "Call failed because microphone could not be accessed. " +
"Check that a microphone is plugged in and set up correctly.", "Check that a microphone is plugged in and set up correctly.",
)} )}
</div>; </div>;
} else if (call.type === CallType.Video) { } else if (call.type === CallType.Video) {
title = _t("Unable to access webcam / microphone"); title = _t("Unable to access webcam / microphone");
description = <div> description = <div>
{_t("Call failed because no webcam or microphone could not be accessed. Check that:")} {_t("Call failed because webcam or microphone could not be accessed. Check that:")}
<ul> <ul>
<li>{_t("A microphone and webcam are plugged in and set up correctly")}</li> <li>{_t("A microphone and webcam are plugged in and set up correctly")}</li>
<li>{_t("Permission is granted to use the webcam")}</li> <li>{_t("Permission is granted to use the webcam")}</li>

View file

@ -46,6 +46,7 @@ import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from
import SdkConfig from "./SdkConfig"; import SdkConfig from "./SdkConfig";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import {UIFeature} from "./settings/UIFeature"; import {UIFeature} from "./settings/UIFeature";
import {CHAT_EFFECTS} from "./effects"
import CallHandler from "./CallHandler"; import CallHandler from "./CallHandler";
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
@ -78,6 +79,7 @@ export const CommandCategories = {
"actions": _td("Actions"), "actions": _td("Actions"),
"admin": _td("Admin"), "admin": _td("Admin"),
"advanced": _td("Advanced"), "advanced": _td("Advanced"),
"effects": _td("Effects"),
"other": _td("Other"), "other": _td("Other"),
}; };
@ -1094,6 +1096,30 @@ export const Commands = [
category: CommandCategories.messages, category: CommandCategories.messages,
hideCompletionAfterSpace: true, hideCompletionAfterSpace: true,
}), }),
...CHAT_EFFECTS.map((effect) => {
return new Command({
command: effect.command,
description: effect.description(),
args: '<message>',
runFn: function(roomId, args) {
return success((async () => {
if (!args) {
args = effect.fallbackMessage();
MatrixClientPeg.get().sendEmoteMessage(roomId, args);
} else {
const content = {
msgtype: effect.msgType,
body: args,
};
MatrixClientPeg.get().sendMessage(roomId, content);
}
dis.dispatch({action: `effects.${effect.command}`});
})());
},
category: CommandCategories.effects,
})
}),
]; ];
// build a map from names and aliases to the Command objects. // build a map from names and aliases to the Command objects.

View file

@ -69,6 +69,9 @@ import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader"; import RoomHeader from "../views/rooms/RoomHeader";
import {XOR} from "../../@types/common"; import {XOR} from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import EffectsOverlay from "../views/elements/EffectsOverlay";
import {containsEmoji} from '../../effects/utils';
import {CHAT_EFFECTS} from '../../effects';
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import WidgetStore from "../../stores/WidgetStore"; import WidgetStore from "../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../stores/AsyncStore"; import {UPDATE_EVENT} from "../../stores/AsyncStore";
@ -248,6 +251,8 @@ export default class RoomView extends React.Component<IProps, IState> {
this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.context.on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this.context.on("Event.decrypted", this.onEventDecrypted);
this.context.on("event", this.onEvent);
// Start listening for RoomViewStore updates // Start listening for RoomViewStore updates
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
@ -581,6 +586,8 @@ export default class RoomView extends React.Component<IProps, IState> {
this.context.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this.context.removeListener("Event.decrypted", this.onEventDecrypted);
this.context.removeListener("event", this.onEvent);
} }
window.removeEventListener('beforeunload', this.onPageUnload); window.removeEventListener('beforeunload', this.onPageUnload);
@ -781,6 +788,27 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
}; };
private onEventDecrypted = (ev) => {
if (ev.isDecryptionFailure()) return;
this.handleEffects(ev);
};
private onEvent = (ev) => {
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.handleEffects(ev);
};
private handleEffects = (ev) => {
if (!this.state.room ||
!this.state.matrixClientIsReady ||
this.state.room.getUnreadNotificationCount() === 0) return;
CHAT_EFFECTS.forEach(effect => {
if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) {
dis.dispatch({action: `effects.${effect.command}`});
}
})
};
private onRoomName = (room: Room) => { private onRoomName = (room: Room) => {
if (this.state.room && room.roomId == this.state.room.roomId) { if (this.state.room && room.roomId == this.state.room.roomId) {
this.forceUpdate(); this.forceUpdate();
@ -1946,9 +1974,14 @@ export default class RoomView extends React.Component<IProps, IState> {
mx_RoomView_inCall: Boolean(activeCall), mx_RoomView_inCall: Boolean(activeCall),
}); });
const showChatEffects = SettingsStore.getValue('showChatEffects');
return ( return (
<RoomContext.Provider value={this.state}> <RoomContext.Provider value={this.state}>
<main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}> <main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
{showChatEffects && this.roomView.current &&
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />
}
<ErrorBoundary> <ErrorBoundary>
<RoomHeader <RoomHeader
room={this.state.room} room={this.state.room}

View file

@ -149,7 +149,7 @@ export default class MessageContextMenu extends React.Component {
onRedactClick = () => { onRedactClick = () => {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
onFinished: async (proceed) => { onFinished: async (proceed, reason) => {
if (!proceed) return; if (!proceed) return;
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -157,6 +157,8 @@ export default class MessageContextMenu extends React.Component {
await cli.redactEvent( await cli.redactEvent(
this.props.mxEvent.getRoomId(), this.props.mxEvent.getRoomId(),
this.props.mxEvent.getId(), this.props.mxEvent.getId(),
undefined,
reason ? { reason } : {},
); );
} catch (e) { } catch (e) {
const code = e.errcode || e.statusCode; const code = e.errcode || e.statusCode;

View file

@ -23,15 +23,17 @@ import { _t } from '../../../languageHandler';
*/ */
export default class ConfirmRedactDialog extends React.Component { export default class ConfirmRedactDialog extends React.Component {
render() { render() {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog');
return ( return (
<QuestionDialog onFinished={this.props.onFinished} <TextInputDialog onFinished={this.props.onFinished}
title={_t("Confirm Removal")} title={_t("Confirm Removal")}
description={ description={
_t("Are you sure you wish to remove (delete) this event? " + _t("Are you sure you wish to remove (delete) this event? " +
"Note that if you delete a room name or topic change, it could undo the change.")} "Note that if you delete a room name or topic change, it could undo the change.")}
placeholder={_t("Reason (optional)")}
focus
button={_t("Remove")}> button={_t("Remove")}>
</QuestionDialog> </TextInputDialog>
); );
} }
} }

View file

@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {createRef} from 'react'; import React, {createRef} from "react";
import {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery";
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import BaseDialog from './BaseDialog'; import BaseDialog from './BaseDialog';
@ -47,9 +48,10 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
const config = SdkConfig.get(); const config = SdkConfig.get();
this.defaultServer = config["validated_server_config"] as ValidatedServerConfig; this.defaultServer = config["validated_server_config"] as ValidatedServerConfig;
const { serverConfig } = this.props;
this.state = { this.state = {
defaultChosen: this.props.serverConfig.isDefault, defaultChosen: serverConfig.isDefault,
otherHomeserver: this.props.serverConfig.isDefault ? "" : this.props.serverConfig.hsUrl, otherHomeserver: serverConfig.isDefault ? "" : (serverConfig.hsName || serverConfig.hsUrl),
}; };
} }
@ -69,10 +71,25 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org" // find their homeserver without demanding they use "https://matrix.org"
private validate = withValidation<this, { error?: string }>({ private validate = withValidation<this, { error?: string }>({
deriveData: async ({ value: hsUrl }) => { deriveData: async ({ value }) => {
// Always try and use the defaults first let hsUrl = value.trim(); // trim to account for random whitespace
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl) return {}; // if the URL has no protocol, try validate it as a serverName via well-known
if (!hsUrl.includes("://")) {
try {
const discoveryResult = await AutoDiscovery.findClientConfig(hsUrl);
this.validatedConf = AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(hsUrl, discoveryResult);
return {}; // we have a validated config, we don't need to try the other paths
} catch (e) {
console.error(`Attempted ${hsUrl} as a server_name but it failed`, e);
}
}
// if we got to this stage then either the well-known failed or the URL had a protocol specified,
// so validate statically only. If the URL has no protocol, default to https.
if (!hsUrl.includes("://")) {
hsUrl = "https://" + hsUrl;
}
try { try {
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl); this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl);
@ -81,17 +98,22 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
console.error(e); console.error(e);
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e); const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (!stateForError.isFatalError) { if (stateForError.isFatalError) {
// carry on anyway let error = _t("Unable to validate homeserver");
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, null, true);
return {};
} else {
let error = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) { if (e.translatedMessage) {
error = e.translatedMessage; error = e.translatedMessage;
} }
return { error }; return { error };
} }
// try to carry on anyway
try {
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, null, true);
return {};
} catch (e) {
console.error(e);
return { error: _t("Invalid URL") };
}
} }
}, },
rules: [ rules: [
@ -153,7 +175,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
> >
<form className="mx_Dialog_content" id="mx_ServerPickerDialog" onSubmit={this.onSubmit}> <form className="mx_Dialog_content" id="mx_ServerPickerDialog" onSubmit={this.onSubmit}>
<p> <p>
{_t("We call the places you where you can host your account homeservers.")} {text} {_t("We call the places where you can host your account homeservers.")} {text}
</p> </p>
<StyledRadioButton <StyledRadioButton

View file

@ -0,0 +1,94 @@
/*
Copyright 2020 Nurjin Jafar
Copyright 2020 Nordeck IT + Consulting GmbH.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FunctionComponent, useEffect, useRef } from 'react';
import dis from '../../../dispatcher/dispatcher';
import ICanvasEffect from '../../../effects/ICanvasEffect';
import {CHAT_EFFECTS} from '../../../effects'
interface IProps {
roomWidth: number;
}
const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const effectsRef = useRef<Map<string, ICanvasEffect>>(new Map<string, ICanvasEffect>());
const lazyLoadEffectModule = async (name: string): Promise<ICanvasEffect> => {
if (!name) return null;
let effect: ICanvasEffect | null = effectsRef.current[name] || null;
if (effect === null) {
const options = CHAT_EFFECTS.find((e) => e.command === name)?.options
try {
const { default: Effect } = await import(`../../../effects/${name}`);
effect = new Effect(options);
effectsRef.current[name] = effect;
} catch (err) {
console.warn('Unable to load effect module at \'../../../effects/${name}\'.', err);
}
}
return effect;
};
useEffect(() => {
const resize = () => {
if (canvasRef.current) {
canvasRef.current.height = window.innerHeight;
}
};
const onAction = (payload: { action: string }) => {
const actionPrefix = 'effects.';
if (payload.action.indexOf(actionPrefix) === 0) {
const effect = payload.action.substr(actionPrefix.length);
lazyLoadEffectModule(effect).then((module) => module?.start(canvasRef.current));
}
}
const dispatcherRef = dis.register(onAction);
const canvas = canvasRef.current;
canvas.height = window.innerHeight;
window.addEventListener('resize', resize, true);
return () => {
dis.unregister(dispatcherRef);
window.removeEventListener('resize', resize);
// eslint-disable-next-line react-hooks/exhaustive-deps
const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored
for (const effect in currentEffects) {
const effectModule: ICanvasEffect = currentEffects[effect];
if (effectModule && effectModule.isRunning) {
effectModule.stop();
}
}
};
}, []);
return (
<canvas
ref={canvasRef}
width={roomWidth}
style={{
display: 'block',
zIndex: 999999,
pointerEvents: 'none',
position: 'fixed',
top: 0,
right: 0,
}}
/>
)
}
export default EffectsOverlay;

View file

@ -42,6 +42,8 @@ import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RateLimitedFunc from '../../../ratelimitedfunc'; import RateLimitedFunc from '../../../ratelimitedfunc';
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import {containsEmoji} from "../../../effects/utils";
import {CHAT_EFFECTS} from '../../../effects';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
@ -326,6 +328,11 @@ export default class SendMessageComposer extends React.Component {
}); });
} }
dis.dispatch({action: "message_sent"}); dis.dispatch({action: "message_sent"});
CHAT_EFFECTS.forEach((effect) => {
if (containsEmoji(content, effect.emojis)) {
dis.dispatch({action: `effects.${effect.command}`});
}
});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
} }

View file

@ -50,6 +50,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
'showAvatarChanges', 'showAvatarChanges',
'showDisplaynameChanges', 'showDisplaynameChanges',
'showImages', 'showImages',
'showChatEffects',
'Pill.shouldShowPillAvatar', 'Pill.shouldShowPillAvatar',
]; ];

View file

@ -0,0 +1,47 @@
/*
Copyright 2020 Nurjin Jafar
Copyright 2020 Nordeck IT + Consulting GmbH.
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.
*/
/**
* Defines the constructor of a canvas based room effect
*/
export interface ICanvasEffectConstructable {
/**
* @param {{[key:string]:any}} options? Optional animation options
* @returns ICanvasEffect Returns a new instance of the canvas effect
*/
new(options?: { [key: string]: any }): ICanvasEffect;
}
/**
* Defines the interface of a canvas based room effect
*/
export default interface ICanvasEffect {
/**
* @param {HTMLCanvasElement} canvas The canvas instance as the render target of the animation
* @param {number} timeout? A timeout that defines the runtime of the animation (defaults to false)
*/
start: (canvas: HTMLCanvasElement, timeout?: number) => Promise<void>;
/**
* Stops the current animation
*/
stop: () => Promise<void>;
/**
* Returns a value that defines if the animation is currently running
*/
isRunning: boolean;
}

View file

@ -0,0 +1,191 @@
/*
Copyright 2020 Nurjin Jafar
Copyright 2020 Nordeck IT + Consulting GmbH.
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 ICanvasEffect from '../ICanvasEffect';
export type ConfettiOptions = {
/**
* max confetti count
*/
maxCount: number,
/**
* particle animation speed
*/
speed: number,
/**
* the confetti animation frame interval in milliseconds
*/
frameInterval: number,
/**
* the alpha opacity of the confetti (between 0 and 1, where 1 is opaque and 0 is invisible)
*/
alpha: number,
/**
* use gradient instead of solid particle color
*/
gradient: boolean,
}
type ConfettiParticle = {
color: string,
color2: string,
x: number,
y: number,
diameter: number,
tilt: number,
tiltAngleIncrement: number,
tiltAngle: number,
}
export const DefaultOptions: ConfettiOptions = {
maxCount: 150,
speed: 3,
frameInterval: 15,
alpha: 1.0,
gradient: false,
};
export default class Confetti implements ICanvasEffect {
private readonly options: ConfettiOptions;
constructor(options: { [key: string]: any }) {
this.options = {...DefaultOptions, ...options};
}
private context: CanvasRenderingContext2D | null = null;
private supportsAnimationFrame = window.requestAnimationFrame;
private colors = ['rgba(30,144,255,', 'rgba(107,142,35,', 'rgba(255,215,0,',
'rgba(255,192,203,', 'rgba(106,90,205,', 'rgba(173,216,230,',
'rgba(238,130,238,', 'rgba(152,251,152,', 'rgba(70,130,180,',
'rgba(244,164,96,', 'rgba(210,105,30,', 'rgba(220,20,60,'];
private lastFrameTime = Date.now();
private particles: Array<ConfettiParticle> = [];
private waveAngle = 0;
public isRunning: boolean;
public start = async (canvas: HTMLCanvasElement, timeout = 3000) => {
if (!canvas) {
return;
}
this.context = canvas.getContext('2d');
this.particles = [];
const count = this.options.maxCount;
while (this.particles.length < count) {
this.particles.push(this.resetParticle({} as ConfettiParticle, canvas.width, canvas.height));
}
this.isRunning = true;
this.runAnimation();
if (timeout) {
window.setTimeout(this.stop, timeout);
}
}
public stop = async () => {
this.isRunning = false;
}
private resetParticle = (particle: ConfettiParticle, width: number, height: number): ConfettiParticle => {
particle.color = this.colors[(Math.random() * this.colors.length) | 0] + (this.options.alpha + ')');
if (this.options.gradient) {
particle.color2 = this.colors[(Math.random() * this.colors.length) | 0] + (this.options.alpha + ')');
} else {
particle.color2 = particle.color;
}
particle.x = Math.random() * width;
particle.y = Math.random() * -height;
particle.diameter = Math.random() * 10 + 5;
particle.tilt = Math.random() * -10;
particle.tiltAngleIncrement = Math.random() * 0.07 + 0.05;
particle.tiltAngle = Math.random() * Math.PI;
return particle;
}
private runAnimation = (): void => {
if (!this.context || !this.context.canvas) {
return;
}
if (this.particles.length === 0) {
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
} else {
const now = Date.now();
const delta = now - this.lastFrameTime;
if (!this.supportsAnimationFrame || delta > this.options.frameInterval) {
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
this.updateParticles();
this.drawParticles(this.context);
this.lastFrameTime = now - (delta % this.options.frameInterval);
}
requestAnimationFrame(this.runAnimation);
}
}
private drawParticles = (context: CanvasRenderingContext2D): void => {
if (!this.context || !this.context.canvas) {
return;
}
let x; let x2; let y2;
for (const particle of this.particles) {
this.context.beginPath();
context.lineWidth = particle.diameter;
x2 = particle.x + particle.tilt;
x = x2 + particle.diameter / 2;
y2 = particle.y + particle.tilt + particle.diameter / 2;
if (this.options.gradient) {
const gradient = context.createLinearGradient(x, particle.y, x2, y2);
gradient.addColorStop(0, particle.color);
gradient.addColorStop(1.0, particle.color2);
context.strokeStyle = gradient;
} else {
context.strokeStyle = particle.color;
}
context.moveTo(x, particle.y);
context.lineTo(x2, y2);
context.stroke();
}
}
private updateParticles = () => {
if (!this.context || !this.context.canvas) {
return;
}
const width = this.context.canvas.width;
const height = this.context.canvas.height;
let particle: ConfettiParticle;
this.waveAngle += 0.01;
for (let i = 0; i < this.particles.length; i++) {
particle = this.particles[i];
if (!this.isRunning && particle.y < -15) {
particle.y = height + 100;
} else {
particle.tiltAngle += particle.tiltAngleIncrement;
particle.x += Math.sin(this.waveAngle) - 0.5;
particle.y += (Math.cos(this.waveAngle) + particle.diameter + this.options.speed) * 0.5;
particle.tilt = Math.sin(particle.tiltAngle) * 15;
}
if (particle.x > width + 20 || particle.x < -20 || particle.y > height) {
if (this.isRunning && this.particles.length <= this.options.maxCount) {
this.resetParticle(particle, width, height);
} else {
this.particles.splice(i, 1);
i--;
}
}
}
}
}

89
src/effects/index.ts Normal file
View file

@ -0,0 +1,89 @@
/*
Copyright 2020 Nurjin Jafar
Copyright 2020 Nordeck IT + Consulting GmbH.
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 { _t, _td } from "../languageHandler";
export type Effect<TOptions extends { [key: string]: any }> = {
/**
* one or more emojis that will trigger this effect
*/
emojis: Array<string>;
/**
* the matrix message type that will trigger this effect
*/
msgType: string;
/**
* the room command to trigger this effect
*/
command: string;
/**
* a function that returns the translated description of the effect
*/
description: () => string;
/**
* a function that returns the translated fallback message. this message will be shown if the user did not provide a custom message
*/
fallbackMessage: () => string;
/**
* animation options
*/
options: TOptions;
}
type ConfettiOptions = {
/**
* max confetti count
*/
maxCount: number,
/**
* particle animation speed
*/
speed: number,
/**
* the confetti animation frame interval in milliseconds
*/
frameInterval: number,
/**
* the alpha opacity of the confetti (between 0 and 1, where 1 is opaque and 0 is invisible)
*/
alpha: number,
/**
* use gradient instead of solid particle color
*/
gradient: boolean,
}
/**
* This configuration defines room effects that can be triggered by custom message types and emojis
*/
export const CHAT_EFFECTS: Array<Effect<{ [key: string]: any }>> = [
{
emojis: ['🎊', '🎉'],
msgType: 'nic.custom.confetti',
command: 'confetti',
description: () => _td("Sends the given message with confetti"),
fallbackMessage: () => _t("sends confetti") + " 🎉",
options: {
maxCount: 150,
speed: 3,
frameInterval: 15,
alpha: 1.0,
gradient: false,
},
} as Effect<ConfettiOptions>,
];

24
src/effects/utils.ts Normal file
View file

@ -0,0 +1,24 @@
/*
Copyright 2020 Nurjin Jafar
Copyright 2020 Nordeck IT + Consulting GmbH.
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.
*/
/**
* Checks a message if it contains one of the provided emojis
* @param {Object} content The message
* @param {Array<string>} emojis The list of emojis to check for
*/
export const containsEmoji = (content: { msgtype: string, body: string }, emojis: Array<string>): boolean => {
return emojis.some((emoji) => content.body && content.body.includes(emoji));
}

View file

@ -47,9 +47,9 @@
"Try using turn.matrix.org": "Try using turn.matrix.org", "Try using turn.matrix.org": "Try using turn.matrix.org",
"OK": "OK", "OK": "OK",
"Unable to access microphone": "Unable to access microphone", "Unable to access microphone": "Unable to access microphone",
"Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.", "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.",
"Unable to access webcam / microphone": "Unable to access webcam / microphone", "Unable to access webcam / microphone": "Unable to access webcam / microphone",
"Call failed because no webcam or microphone could not be accessed. Check that:": "Call failed because no webcam or microphone could not be accessed. Check that:", "Call failed because webcam or microphone could not be accessed. Check that:": "Call failed because webcam or microphone could not be accessed. Check that:",
"A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly", "A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly",
"Permission is granted to use the webcam": "Permission is granted to use the webcam", "Permission is granted to use the webcam": "Permission is granted to use the webcam",
"No other application is using the webcam": "No other application is using the webcam", "No other application is using the webcam": "No other application is using the webcam",
@ -406,6 +406,7 @@
"Messages": "Messages", "Messages": "Messages",
"Actions": "Actions", "Actions": "Actions",
"Advanced": "Advanced", "Advanced": "Advanced",
"Effects": "Effects",
"Other": "Other", "Other": "Other",
"Command error": "Command error", "Command error": "Command error",
"Usage": "Usage", "Usage": "Usage",
@ -826,6 +827,7 @@
"Manually verify all remote sessions": "Manually verify all remote sessions", "Manually verify all remote sessions": "Manually verify all remote sessions",
"IRC display name width": "IRC display name width", "IRC display name width": "IRC display name width",
"Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout", "Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout",
"Show chat effects": "Show chat effects",
"Collecting app version information": "Collecting app version information", "Collecting app version information": "Collecting app version information",
"Collecting logs": "Collecting logs", "Collecting logs": "Collecting logs",
"Uploading logs": "Uploading logs", "Uploading logs": "Uploading logs",
@ -844,6 +846,8 @@
"When rooms are upgraded": "When rooms are upgraded", "When rooms are upgraded": "When rooms are upgraded",
"My Ban List": "My Ban List", "My Ban List": "My Ban List",
"This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
"Sends the given message with confetti": "Sends the given message with confetti",
"sends confetti": "sends confetti",
"You held the call <a>Resume</a>": "You held the call <a>Resume</a>", "You held the call <a>Resume</a>": "You held the call <a>Resume</a>",
"%(peerName)s held the call": "%(peerName)s held the call", "%(peerName)s held the call": "%(peerName)s held the call",
"Video Call": "Video Call", "Video Call": "Video Call",
@ -1964,6 +1968,7 @@
"Removing…": "Removing…", "Removing…": "Removing…",
"Confirm Removal": "Confirm Removal", "Confirm Removal": "Confirm Removal",
"Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.", "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.",
"Reason (optional)": "Reason (optional)",
"Clear all data in this session?": "Clear all data in this session?", "Clear all data in this session?": "Clear all data in this session?",
"Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.", "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.",
"Clear all data": "Clear all data", "Clear all data": "Clear all data",
@ -2157,11 +2162,12 @@
"A connection error occurred while trying to contact the server.": "A connection error occurred while trying to contact the server.", "A connection error occurred while trying to contact the server.": "A connection error occurred while trying to contact the server.",
"The server is not configured to indicate what the problem is (CORS).": "The server is not configured to indicate what the problem is (CORS).", "The server is not configured to indicate what the problem is (CORS).": "The server is not configured to indicate what the problem is (CORS).",
"Recent changes that have not yet been received": "Recent changes that have not yet been received", "Recent changes that have not yet been received": "Recent changes that have not yet been received",
"Unable to validate homeserver/identity server": "Unable to validate homeserver/identity server", "Unable to validate homeserver": "Unable to validate homeserver",
"Invalid URL": "Invalid URL",
"Specify a homeserver": "Specify a homeserver", "Specify a homeserver": "Specify a homeserver",
"Matrix.org is the biggest public homeserver in the world, so its a good place for many.": "Matrix.org is the biggest public homeserver in the world, so its a good place for many.", "Matrix.org is the biggest public homeserver in the world, so its a good place for many.": "Matrix.org is the biggest public homeserver in the world, so its a good place for many.",
"Sign into your homeserver": "Sign into your homeserver", "Sign into your homeserver": "Sign into your homeserver",
"We call the places you where you can host your account homeservers.": "We call the places you where you can host your account homeservers.", "We call the places where you can host your account homeservers.": "We call the places where you can host your account homeservers.",
"Other homeserver": "Other homeserver", "Other homeserver": "Other homeserver",
"Use your preferred Matrix homeserver if you have one, or host your own.": "Use your preferred Matrix homeserver if you have one, or host your own.", "Use your preferred Matrix homeserver if you have one, or host your own.": "Use your preferred Matrix homeserver if you have one, or host your own.",
"Learn more": "Learn more", "Learn more": "Learn more",

View file

@ -634,6 +634,11 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td("Enable experimental, compact IRC style layout"), displayName: _td("Enable experimental, compact IRC style layout"),
default: false, default: false,
}, },
"showChatEffects": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Show chat effects"),
default: true,
},
"Widgets.pinned": { "Widgets.pinned": {
supportedLevels: LEVELS_ROOM_OR_ACCOUNT, supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
default: {}, default: {},

View file

@ -42,7 +42,7 @@ for (const key of Object.keys(SETTINGS)) {
if (SETTINGS[key].invertedSettingName) { if (SETTINGS[key].invertedSettingName) {
// Invert now so that the rest of the system will invert it back // Invert now so that the rest of the system will invert it back
// to what was intended. // to what was intended.
invertedDefaultSettings[key] = !SETTINGS[key].default; invertedDefaultSettings[SETTINGS[key].invertedSettingName] = !SETTINGS[key].default;
} }
} }

View file

@ -1256,10 +1256,10 @@
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.11.2": "@babel/runtime@^7.12.5":
version "7.11.2" version "7.12.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
@ -4842,9 +4842,9 @@ has@^1.0.1, has@^1.0.3:
function-bind "^1.1.1" function-bind "^1.1.1"
highlight.js@^10.1.2: highlight.js@^10.1.2:
version "10.1.2" version "10.4.1"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.1.2.tgz#c20db951ba1c22c055010648dfffd7b2a968e00c" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0"
integrity sha512-Q39v/Mn5mfBlMff9r+zzA+gWxRsCRKwEMvYTiisLr/XUiFI/4puWt0Ojdko3R3JCNWGdOWaA5g/Yxqa23kC5AA== integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg==
hoist-non-react-statics@^3.3.0: hoist-non-react-statics@^3.3.0:
version "3.3.2" version "3.3.2"
@ -6390,10 +6390,10 @@ log-symbols@^2.0.0, log-symbols@^2.2.0:
dependencies: dependencies:
chalk "^2.0.1" chalk "^2.0.1"
loglevel@^1.7.0: loglevel@^1.7.1:
version "1.7.0" version "1.7.1"
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.0.tgz#728166855a740d59d38db01cf46f042caa041bb0" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197"
integrity sha512-i2sY04nal5jDcagM3FMfG++T69GEEM8CYuOfeOIvmXzOIcwE9a/CJPR0MFM97pYMj/u10lzz7/zd7+qwhrBTqQ== integrity sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw==
lolex@^5.0.0, lolex@^5.1.2: lolex@^5.0.0, lolex@^5.1.2:
version "5.1.2" version "5.1.2"
@ -6513,15 +6513,15 @@ mathml-tag-names@^2.0.1:
integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
version "9.2.0" version "9.3.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/6661bde6088e6e43f31198e8532432e162aef33c" resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ff6612f9d0aa1a7c08b65a0b41c5ab997506016f"
dependencies: dependencies:
"@babel/runtime" "^7.11.2" "@babel/runtime" "^7.12.5"
another-json "^0.2.0" another-json "^0.2.0"
browser-request "^0.3.3" browser-request "^0.3.3"
bs58 "^4.0.1" bs58 "^4.0.1"
content-type "^1.0.4" content-type "^1.0.4"
loglevel "^1.7.0" loglevel "^1.7.1"
qs "^6.9.4" qs "^6.9.4"
request "^2.88.2" request "^2.88.2"
unhomoglyph "^1.0.6" unhomoglyph "^1.0.6"