From 6778c06923a9d567e1ee40bde1666a5911f3bb83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 21 Nov 2021 14:05:09 +0100 Subject: [PATCH 01/12] Add `getDevice()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/MediaDeviceHandler.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts index ddf1977bf04..3c41e5be952 100644 --- a/src/MediaDeviceHandler.ts +++ b/src/MediaDeviceHandler.ts @@ -124,4 +124,17 @@ export default class MediaDeviceHandler extends EventEmitter { public static getVideoInput(): string { return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput"); } + + /** + * Returns the current set deviceId for a device kind + * @param {MediaDeviceKindEnum} kind of the device that will be returned + * @returns {string} the deviceId + */ + public static getDevice(kind: MediaDeviceKindEnum): string { + switch (kind) { + case MediaDeviceKindEnum.AudioOutput: return this.getAudioOutput(); + case MediaDeviceKindEnum.AudioInput: return this.getAudioInput(); + case MediaDeviceKindEnum.VideoInput: return this.getVideoInput(); + } + } } From 904e31d1391f6142c20fa571c5a41d1a2c0817dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 21 Nov 2021 15:39:06 +0100 Subject: [PATCH 02/12] Add `onHover()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/elements/AccessibleTooltipButton.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index 7f40662efe4..dac481f45bf 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -29,6 +29,7 @@ interface ITooltipProps extends React.ComponentProps { forceHide?: boolean; yOffset?: number; alignment?: Alignment; + onHover?: (hovering: boolean) => void; } interface IState { @@ -53,6 +54,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { + if (this.props.onHover) this.props.onHover(true); if (this.props.forceHide) return; this.setState({ hover: true, @@ -60,6 +62,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { + if (this.props.onHover) this.props.onHover(false); this.setState({ hover: false, }); From f8d0bf036bd6873a5d5b8170a4cbde5e2a529d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 21 Nov 2021 15:44:15 +0100 Subject: [PATCH 03/12] Give call view buttons a box-shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/views/voip/CallView/_CallViewButtons.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/res/css/views/voip/CallView/_CallViewButtons.scss b/res/css/views/voip/CallView/_CallViewButtons.scss index 9305d07f3b7..bd2e879a95d 100644 --- a/res/css/views/voip/CallView/_CallViewButtons.scss +++ b/res/css/views/voip/CallView/_CallViewButtons.scss @@ -46,6 +46,8 @@ limitations under the License. justify-content: center; align-items: center; + box-shadow: 0px 4px 4px 0px #00000026; // Same on both themes + &::before { content: ''; display: inline-block; From 1724c96dcdf5a00be8efec873eaa59464062403e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Mon, 22 Nov 2021 14:54:53 +0100 Subject: [PATCH 04/12] Add styling for DeviceContextMenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/_components.scss | 1 + .../context_menus/_DeviceContextMenu.scss | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 res/css/views/context_menus/_DeviceContextMenu.scss diff --git a/res/css/_components.scss b/res/css/_components.scss index 323dc2841b4..e7ec2056139 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -64,6 +64,7 @@ @import "./views/avatars/_WidgetAvatar.scss"; @import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; +@import "./views/context_menus/_DeviceContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; diff --git a/res/css/views/context_menus/_DeviceContextMenu.scss b/res/css/views/context_menus/_DeviceContextMenu.scss new file mode 100644 index 00000000000..4b886279d7d --- /dev/null +++ b/res/css/views/context_menus/_DeviceContextMenu.scss @@ -0,0 +1,27 @@ +/* +Copyright 2021 Šimon Brandner + +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. +*/ + +.mx_DeviceContextMenu { + max-width: 252px; + + .mx_DeviceContextMenu_device_icon { + display: none; + } + + .mx_IconizedContextMenu_label { + padding-left: 0 !important; + } +} From 750cdb63c2cf168587492c2db51f58c905d79e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Mon, 22 Nov 2021 16:00:41 +0100 Subject: [PATCH 05/12] Add IconizedContextMenuOptionList label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/views/context_menus/_IconizedContextMenu.scss | 5 +++++ .../views/context_menus/IconizedContextMenu.tsx | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 01ca8a419d8..81fd0a264e1 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -25,6 +25,11 @@ limitations under the License. padding-right: 20px; } + .mx_IconizedContextMenu_optionList_label { + font-size: $font-15px; + font-weight: $font-semi-bold; + } + // the notFirst class is for cases where the optionList might be under a header of sorts. &:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst { // This is a bit of a hack when we could just use a simple border-top property, diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index 2c6bdb3776b..9b7896790ef 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -33,6 +33,7 @@ interface IProps extends IContextMenuProps { interface IOptionListProps { first?: boolean; red?: boolean; + label?: string; className?: string; } @@ -126,13 +127,20 @@ export const IconizedContextMenuOption: React.FC = ({ ; }; -export const IconizedContextMenuOptionList: React.FC = ({ first, red, className, children }) => { +export const IconizedContextMenuOptionList: React.FC = ({ + first, + red, + className, + label, + children, +}) => { const classes = classNames("mx_IconizedContextMenu_optionList", className, { mx_IconizedContextMenu_optionList_notFirst: !first, mx_IconizedContextMenu_optionList_red: red, }); return
+ { label &&
{ label }
} { children }
; }; From 80d3f2fd2c25ceb4a5180ef5a7423f7ba273edd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Wed, 24 Nov 2021 17:27:20 +0100 Subject: [PATCH 06/12] Add DeviceContextMenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- .../views/context_menus/DeviceContextMenu.tsx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/components/views/context_menus/DeviceContextMenu.tsx diff --git a/src/components/views/context_menus/DeviceContextMenu.tsx b/src/components/views/context_menus/DeviceContextMenu.tsx new file mode 100644 index 00000000000..04463e81ff0 --- /dev/null +++ b/src/components/views/context_menus/DeviceContextMenu.tsx @@ -0,0 +1,89 @@ +/* +Copyright 2021 Šimon Brandner + +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, { useEffect, useState } from "react"; + +import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; +import IconizedContextMenu, { IconizedContextMenuOptionList, IconizedContextMenuRadio } from "./IconizedContextMenu"; +import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; +import { _t, _td } from "../../../languageHandler"; + +const SECTION_NAMES: Record = { + [MediaDeviceKindEnum.AudioInput]: _td("Input devices"), + [MediaDeviceKindEnum.AudioOutput]: _td("Output devices"), + [MediaDeviceKindEnum.VideoInput]: _td("Cameras"), +}; + +interface IDeviceContextMenuDeviceProps { + label: string; + selected: boolean; + onClick: () => void; +} + +const DeviceContextMenuDevice: React.FC = ({ label, selected, onClick }) => { + return ; +}; + +interface IDeviceContextMenuSectionProps { + deviceKind: MediaDeviceKindEnum; +} + +const DeviceContextMenuSection: React.FC = ({ deviceKind }) => { + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(MediaDeviceHandler.getDevice(deviceKind)); + + useEffect(() => { + const getDevices = async () => { + return setDevices((await MediaDeviceHandler.getDevices())[deviceKind]); + }; + getDevices(); + }, [deviceKind]); + + const onDeviceClick = (deviceId: string): void => { + MediaDeviceHandler.instance.setDevice(deviceId, deviceKind); + setSelectedDevice(deviceId); + }; + + return + { devices.map(({ label, deviceId }) => { + return onDeviceClick(deviceId)} + />; + }) } + ; +}; + +interface IProps extends IContextMenuProps { + deviceKinds: MediaDeviceKind[]; +} + +const DeviceContextMenu: React.FC = ({ deviceKinds, ...props }) => { + return + { deviceKinds.map((kind) => { + return ; + }) } + ; +}; + +export default DeviceContextMenu; From e7dcef9e32887f0011cf8bffb683256772d70c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Wed, 24 Nov 2021 17:28:38 +0100 Subject: [PATCH 07/12] Add CallViewDropdownButton styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- .../views/voip/CallView/_CallViewButtons.scss | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/res/css/views/voip/CallView/_CallViewButtons.scss b/res/css/views/voip/CallView/_CallViewButtons.scss index bd2e879a95d..380b9727647 100644 --- a/res/css/views/voip/CallView/_CallViewButtons.scss +++ b/res/css/views/voip/CallView/_CallViewButtons.scss @@ -46,6 +46,8 @@ limitations under the License. justify-content: center; align-items: center; + position: relative; + box-shadow: 0px 4px 4px 0px #00000026; // Same on both themes &::before { @@ -62,6 +64,25 @@ limitations under the License. width: 24px; } + &.mx_CallViewButtons_dropdownButton { + width: 16px; + height: 16px; + + position: absolute; + right: 0; + bottom: 0; + + &::before { + width: 14px; + height: 14px; + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); + } + + &.mx_CallViewButtons_dropdownButton_collapsed::before { + transform: rotate(180deg); + } + } + // State buttons &.mx_CallViewButtons_button_on { background-color: $call-view-button-on-background; From c9980954edf97ec207b9cd43708d0bb0f0fb2ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Wed, 24 Nov 2021 17:28:44 +0100 Subject: [PATCH 08/12] Add CallViewDropdownButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- .../views/voip/CallView/CallViewButtons.tsx | 70 ++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/src/components/views/voip/CallView/CallViewButtons.tsx b/src/components/views/voip/CallView/CallViewButtons.tsx index 1d373694b39..02eb2851584 100644 --- a/src/components/views/voip/CallView/CallViewButtons.tsx +++ b/src/components/views/voip/CallView/CallViewButtons.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from "react"; +import React, { createRef, useState } from "react"; import classNames from "classnames"; import { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; @@ -26,10 +26,14 @@ import DialpadContextMenu from "../../context_menus/DialpadContextMenu"; import { Alignment } from "../../elements/Tooltip"; import { alwaysAboveLeftOf, + alwaysAboveRightOf, ChevronFace, ContextMenuTooltipButton, + useContextMenu, } from '../../../structures/ContextMenu'; import { _t } from "../../../../languageHandler"; +import DeviceContextMenu from "../../context_menus/DeviceContextMenu"; +import { MediaDeviceKindEnum } from "../../../../MediaDeviceHandler"; // Height of the header duplicated from CSS because we need to subtract it from our max // height to get the max height of the video @@ -39,15 +43,22 @@ const TOOLTIP_Y_OFFSET = -24; const CONTROLS_HIDE_DELAY = 2000; -interface IButtonProps { +interface IButtonProps extends Omit, "title"> { state: boolean; className: string; - onLabel: string; - offLabel: string; - onClick: () => void; + onLabel?: string; + offLabel?: string; + onClick: (event: React.MouseEvent) => void; } -const CallViewToggleButton: React.FC = ({ state: isOn, className, onLabel, offLabel, onClick }) => { +const CallViewToggleButton: React.FC = ({ + children, + state: isOn, + className, + onLabel, + offLabel, + ...props +}) => { const classes = classNames("mx_CallViewButtons_button", className, { mx_CallViewButtons_button_on: isOn, mx_CallViewButtons_button_off: !isOn, @@ -56,11 +67,48 @@ const CallViewToggleButton: React.FC = ({ state: isOn, className, return ( + {...props} + > + { children } + + ); +}; + +interface IDropdownButtonProps extends IButtonProps { + deviceKinds: MediaDeviceKindEnum[]; +} + +const CallViewDropdownButton: React.FC = ({ state, deviceKinds, ...props }) => { + const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu(); + const [hoveringDropdown, setHoveringDropdown] = useState(false); + + const classes = classNames("mx_CallViewButtons_button", "mx_CallViewButtons_dropdownButton", { + mx_CallViewButtons_dropdownButton_collapsed: !menuDisplayed, + }); + + const onClick = (event: React.MouseEvent): void => { + event.stopPropagation(); + openMenu(); + }; + + return ( + + setHoveringDropdown(hovering)} + state={state} + /> + { menuDisplayed && } + ); }; @@ -221,19 +269,21 @@ export default class CallViewButtons extends React.Component { alignment={Alignment.Top} yOffset={TOOLTIP_Y_OFFSET} /> } - - { this.props.buttonsVisibility.vidMute && } { this.props.buttonsVisibility.screensharing && Date: Wed, 24 Nov 2021 17:28:56 +0100 Subject: [PATCH 09/12] i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/i18n/strings/en_EN.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 10cf93fe48e..ff69b0b81c6 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2866,6 +2866,9 @@ "If you've forgotten your Security Key you can ": "If you've forgotten your Security Key you can ", "Resume": "Resume", "Hold": "Hold", + "Input devices": "Input devices", + "Output devices": "Output devices", + "Cameras": "Cameras", "Reject invitation": "Reject invitation", "Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?", "Unable to reject invite": "Unable to reject invite", From dc9192cf32589243c02ad72fc4b52ed3c4621cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Wed, 24 Nov 2021 17:37:12 +0100 Subject: [PATCH 10/12] Add a defautl for WebRTC device settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/settings/Settings.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index f60587a7e7f..0f05b300964 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -569,15 +569,15 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "webrtc_audiooutput": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, - default: null, + default: "default", }, "webrtc_audioinput": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, - default: null, + default: "default", }, "webrtc_videoinput": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, - default: null, + default: "default", }, "language": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, From 364db4639ec52f606d4a88c6f174360909c9f5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Fri, 4 Feb 2022 13:09:20 +0100 Subject: [PATCH 11/12] Make `setDevice()` methods async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/MediaDeviceHandler.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts index 3c41e5be952..59f624f0808 100644 --- a/src/MediaDeviceHandler.ts +++ b/src/MediaDeviceHandler.ts @@ -72,12 +72,12 @@ export default class MediaDeviceHandler extends EventEmitter { /** * Retrieves devices from the SettingsStore and tells the js-sdk to use them */ - public static loadDevices(): void { + public static async loadDevices(): Promise { const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId); - MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId); + await MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId); + await MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId); } public setAudioOutput(deviceId: string): void { @@ -90,9 +90,9 @@ export default class MediaDeviceHandler extends EventEmitter { * need to be ended and started again for this change to take effect * @param {string} deviceId */ - public setAudioInput(deviceId: string): void { + public async setAudioInput(deviceId: string): Promise { SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); - MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId); + return MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId); } /** @@ -100,16 +100,16 @@ export default class MediaDeviceHandler extends EventEmitter { * need to be ended and started again for this change to take effect * @param {string} deviceId */ - public setVideoInput(deviceId: string): void { + public async setVideoInput(deviceId: string): Promise { SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); - MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId); + return MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId); } - public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { + public async setDevice(deviceId: string, kind: MediaDeviceKindEnum): Promise { switch (kind) { case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break; - case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break; - case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break; + case MediaDeviceKindEnum.AudioInput: await this.setAudioInput(deviceId); break; + case MediaDeviceKindEnum.VideoInput: await this.setVideoInput(deviceId); break; } } From ed97738f78db98a5c4ca993f87aba150da875157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sat, 5 Feb 2022 13:01:53 +0100 Subject: [PATCH 12/12] `playMedia()` in `onNewStream()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/voip/VideoFeed.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index c000b3e08d5..dcc2d90d730 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -161,6 +161,7 @@ export default class VideoFeed extends React.PureComponent { audioMuted: this.props.feed.isAudioMuted(), videoMuted: this.props.feed.isVideoMuted(), }); + this.playMedia(); }; private onMuteStateChanged = () => {