Merge remote-tracking branch 'origin/develop' into feat/matrix-wysisyg-integration

This commit is contained in:
Florian Duros 2022-10-10 17:04:27 +02:00
commit 5bdac78fc7
No known key found for this signature in database
GPG key ID: 9700AA5870258A0B
150 changed files with 3632 additions and 980 deletions

View file

@ -493,7 +493,6 @@ export class EmailIdentityAuthEntry extends
? _t("Resent!")
: _t("Resend")}
alignment={Alignment.Right}
tooltipClassName="mx_Tooltip_noMargin"
onHideTooltip={this.state.requested
? () => this.setState({ requested: false })
: undefined}

View file

@ -32,8 +32,10 @@ import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import BaseDialog from "./BaseDialog";
import { Action } from '../../../dispatcher/actions';
import { VoipRoomSettingsTab } from "../settings/tabs/room/VoipRoomSettingsTab";
export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB";
export const ROOM_VOIP_TAB = "ROOM_VOIP_TAB";
export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB";
export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB";
export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB";
@ -96,6 +98,14 @@ export default class RoomSettingsDialog extends React.Component<IProps, IState>
<GeneralRoomSettingsTab roomId={this.props.roomId} />,
"RoomSettingsGeneral",
));
if (SettingsStore.getValue("feature_group_calls")) {
tabs.push(new Tab(
ROOM_VOIP_TAB,
_td("Voice & Video"),
"mx_RoomSettingsDialog_voiceIcon",
<VoipRoomSettingsTab roomId={this.props.roomId} />,
));
}
tabs.push(new Tab(
ROOM_SECURITY_TAB,
_td("Security & Privacy"),

View file

@ -21,7 +21,7 @@ import AccessibleButton from "./AccessibleButton";
import Tooltip, { Alignment } from './Tooltip';
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
title: string;
title?: string;
tooltip?: React.ReactNode;
label?: string;
tooltipClassName?: string;
@ -78,7 +78,7 @@ export default class AccessibleTooltipButton extends React.PureComponent<IProps,
const { title, tooltip, children, tooltipClassName, forceHide, alignment, onHideTooltip,
...props } = this.props;
const tip = this.state.hover && <Tooltip
const tip = this.state.hover && (title || tooltip) && <Tooltip
tooltipClassName={tooltipClassName}
label={tooltip || title}
alignment={alignment}
@ -86,11 +86,11 @@ export default class AccessibleTooltipButton extends React.PureComponent<IProps,
return (
<AccessibleButton
{...props}
onMouseOver={this.showTooltip}
onMouseLeave={this.hideTooltip}
onFocus={this.onFocus}
onBlur={this.hideTooltip}
aria-label={title}
onMouseOver={this.showTooltip || props.onMouseOver}
onMouseLeave={this.hideTooltip || props.onMouseLeave}
onFocus={this.onFocus || props.onFocus}
onBlur={this.hideTooltip || props.onBlur}
aria-label={title || props["aria-label"]}
>
{ children }
{ this.props.label }

View file

@ -27,6 +27,8 @@ interface IProps {
label: string;
// The translated caption for the switch
caption?: string;
// Tooltip to display
tooltip?: string;
// Whether or not to disable the toggle switch
disabled?: boolean;
// True to put the toggle in front of the label
@ -53,7 +55,8 @@ export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
checked={this.props.value}
disabled={this.props.disabled}
onChange={this.props.onChange}
aria-label={this.props.label}
title={this.props.label}
tooltip={this.props.tooltip}
/>;
if (this.props.toggleInFront) {
@ -66,7 +69,7 @@ export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
"mx_SettingsFlag_toggleInFront": this.props.toggleInFront,
});
return (
<div className={classes}>
<div data-testid={this.props["data-testid"]} className={classes}>
{ firstPart }
{ secondPart }
</div>

View file

@ -114,7 +114,7 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
checked={this.state.value}
onChange={this.onChange}
disabled={this.props.disabled || !canChange}
aria-label={label}
title={label}
/>
</div>
);

View file

@ -18,21 +18,27 @@ limitations under the License.
import React from "react";
import classNames from "classnames";
import AccessibleButton from "./AccessibleButton";
import AccessibleTooltipButton from "./AccessibleTooltipButton";
interface IProps {
// Whether or not this toggle is in the 'on' position.
checked: boolean;
// Title to use
title?: string;
// Whether or not the user can interact with the switch
disabled?: boolean;
// Tooltip to show
tooltip?: string;
// Called when the checked state changes. First argument will be the new state.
onChange(checked: boolean): void;
}
// Controlled Toggle Switch element, written with Accessibility in mind
export default ({ checked, disabled = false, onChange, ...props }: IProps) => {
export default ({ checked, disabled = false, title, tooltip, onChange, ...props }: IProps) => {
const _onClick = () => {
if (disabled) return;
onChange(!checked);
@ -45,14 +51,16 @@ export default ({ checked, disabled = false, onChange, ...props }: IProps) => {
});
return (
<AccessibleButton {...props}
<AccessibleTooltipButton {...props}
className={classes}
onClick={_onClick}
role="switch"
aria-checked={checked}
aria-disabled={disabled}
title={title}
tooltip={tooltip}
>
<div className="mx_ToggleSwitch_ball" />
</AccessibleButton>
</AccessibleTooltipButton>
);
};

View file

@ -149,18 +149,24 @@ export default class Tooltip extends React.PureComponent<ITooltipProps, State> {
break;
case Alignment.Top:
style.top = baseTop - spacing;
style.left = horizontalCenter;
style.transform = "translate(-50%, -100%)";
// Attempt to center the tooltip on the element while clamping
// its horizontal translation to keep it on screen
// eslint-disable-next-line max-len
style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))), -100%)`;
break;
case Alignment.Bottom:
style.top = baseTop + parentBox.height + spacing;
style.left = horizontalCenter;
style.transform = "translate(-50%)";
// Attempt to center the tooltip on the element while clamping
// its horizontal translation to keep it on screen
// eslint-disable-next-line max-len
style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`;
break;
case Alignment.InnerBottom:
style.top = baseTop + parentBox.height - 50;
style.left = horizontalCenter;
style.transform = "translate(-50%)";
// Attempt to center the tooltip on the element while clamping
// its horizontal translation to keep it on screen
// eslint-disable-next-line max-len
style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`;
break;
case Alignment.TopRight:
style.top = baseTop - spacing;

View file

@ -20,7 +20,7 @@ import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Call, ConnectionState } from "../../../models/Call";
import { _t } from "../../../languageHandler";
import { useCall, useConnectionState, useParticipants } from "../../../hooks/useCall";
import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
@ -28,9 +28,9 @@ import type { ButtonEvent } from "../elements/AccessibleButton";
import MemberAvatar from "../avatars/MemberAvatar";
import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary";
import FacePile from "../elements/FacePile";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
const MAX_FACES = 8;
@ -39,6 +39,8 @@ interface ActiveCallEventProps {
participants: Set<RoomMember>;
buttonText: string;
buttonKind: string;
buttonTooltip?: string;
buttonDisabled?: boolean;
onButtonClick: ((ev: ButtonEvent) => void) | null;
}
@ -49,6 +51,8 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
participants,
buttonText,
buttonKind,
buttonDisabled,
buttonTooltip,
onButtonClick,
},
ref,
@ -80,14 +84,15 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
<FacePile members={facePileMembers} faceSize={24} overflow={facePileOverflow} />
</div>
<CallDurationFromEvent mxEvent={mxEvent} />
<AccessibleButton
<AccessibleTooltipButton
className="mx_CallEvent_button"
kind={buttonKind}
disabled={onButtonClick === null}
disabled={onButtonClick === null || buttonDisabled}
onClick={onButtonClick}
tooltip={buttonTooltip}
>
{ buttonText }
</AccessibleButton>
</AccessibleTooltipButton>
</div>
</div>;
},
@ -101,6 +106,7 @@ interface ActiveLoadedCallEventProps {
const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => {
const connectionState = useConnectionState(call);
const participants = useParticipants(call);
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
const connect = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
@ -132,6 +138,8 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
participants={participants}
buttonText={buttonText}
buttonKind={buttonKind}
buttonDisabled={Boolean(joinCallButtonDisabledTooltip)}
buttonTooltip={joinCallButtonDisabledTooltip}
onButtonClick={onButtonClick}
/>;
});

View file

@ -23,6 +23,7 @@ import classNames from 'classnames';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { ButtonEvent } from "../elements/AccessibleButton";
import { Alignment } from "../elements/Tooltip";
interface IProps {
// Whether this button is highlighted
@ -54,6 +55,7 @@ export default class HeaderButton extends React.Component<IProps> {
aria-selected={isHighlighted}
role="tab"
title={title}
alignment={Alignment.Bottom}
className={classes}
onClick={onClick}
/>;

View file

@ -282,7 +282,7 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
<HeaderButton
key="roomSummaryButton"
name="roomSummaryButton"
title={_t('Room Info')}
title={_t('Room info')}
isHighlighted={this.isPhase(ROOM_INFO_PHASES)}
onClick={this.onRoomSummaryClicked}
/>,

View file

@ -31,7 +31,6 @@ import Resizer from "../../../resizer/resizer";
import PercentageDistributor from "../../../resizer/distributors/percentage";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { clamp, percentageOf, percentageWithin } from "../../../utils/numbers";
import { useStateCallback } from "../../../hooks/useStateCallback";
import UIStore from "../../../stores/UIStore";
import { IApp } from "../../../stores/WidgetStore";
import { ActionPayload } from "../../../dispatcher/payloads";
@ -330,13 +329,8 @@ const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
defaultHeight = 280;
}
const [height, setHeight] = useStateCallback(defaultHeight, newHeight => {
newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100;
WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight);
});
return <Resizable
size={{ height: Math.min(height, maxHeight), width: undefined }}
size={{ height: Math.min(defaultHeight, maxHeight), width: undefined }}
minHeight={minHeight}
maxHeight={maxHeight}
onResizeStart={() => {
@ -346,7 +340,15 @@ const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
resizeNotifier.notifyTimelineHeightChanged();
}}
onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height);
let newHeight = defaultHeight + d.height;
newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100;
WidgetLayoutStore.instance.setContainerHeight(
room,
Container.Top,
newHeight,
);
resizeNotifier.stopResizing();
}}
handleWrapperClass={handleWrapperClass}

View file

@ -20,7 +20,7 @@ import classNames from 'classnames';
import { _t, _td } from '../../../languageHandler';
import AccessibleButton from "../elements/AccessibleButton";
import Tooltip from "../elements/Tooltip";
import Tooltip, { Alignment } from "../elements/Tooltip";
import { E2EStatus } from "../../../utils/ShieldUtils";
export enum E2EState {
@ -49,10 +49,20 @@ interface IProps {
size?: number;
onClick?: () => void;
hideTooltip?: boolean;
tooltipAlignment?: Alignment;
bordered?: boolean;
}
const E2EIcon: React.FC<IProps> = ({ isUser, status, className, size, onClick, hideTooltip, bordered }) => {
const E2EIcon: React.FC<IProps> = ({
isUser,
status,
className,
size,
onClick,
hideTooltip,
tooltipAlignment,
bordered,
}) => {
const [hover, setHover] = useState(false);
const classes = classNames({
@ -80,7 +90,7 @@ const E2EIcon: React.FC<IProps> = ({ isUser, status, className, size, onClick, h
let tip;
if (hover && !hideTooltip) {
tip = <Tooltip label={e2eTitle ? _t(e2eTitle) : ""} />;
tip = <Tooltip label={e2eTitle ? _t(e2eTitle) : ""} alignment={tooltipAlignment} />;
}
if (onClick) {

View file

@ -18,6 +18,8 @@ import React, { FC } from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { Call } from "../../../models/Call";
import { useParticipants } from "../../../hooks/useCall";
export enum LiveContentType {
Video,
@ -55,3 +57,18 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
</> }
</span>
);
interface LiveContentSummaryWithCallProps {
call: Call;
}
export function LiveContentSummaryWithCall({ call }: LiveContentSummaryWithCallProps) {
const participants = useParticipants(call);
return <LiveContentSummary
type={LiveContentType.Video}
text={_t("Video")}
active={false}
participantCount={participants.size}
/>;
}

View file

@ -24,7 +24,6 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserTab";
@ -32,7 +31,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
import E2EIcon from './E2EIcon';
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { ButtonEvent } from "../elements/AccessibleButton";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
@ -53,18 +52,21 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler";
import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings";
import SdkConfig from "../../../SdkConfig";
import SdkConfig, { DEFAULTS } from "../../../SdkConfig";
import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { useWidgets } from "../right_panel/RoomSummaryCard";
import { WidgetType } from "../../../widgets/WidgetType";
import { useCall } from "../../../hooks/useCall";
import { useCall, useLayout } from "../../../hooks/useCall";
import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers";
import { ElementCall } from "../../../models/Call";
import { Call, ElementCall, Layout } from "../../../models/Call";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { CallDurationFromEvent } from "../voip/CallDuration";
import { Alignment } from "../elements/Tooltip";
class DisabledWithReason {
constructor(public readonly reason: string) { }
@ -107,6 +109,7 @@ const VoiceCallButton: FC<VoiceCallButtonProps> = ({ room, busy, setBusy, behavi
onClick={onClick}
title={_t("Voice call")}
tooltip={tooltip ?? _t("Voice call")}
alignment={Alignment.Bottom}
disabled={disabled || busy}
/>;
};
@ -192,10 +195,11 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
let menu: JSX.Element | null = null;
if (menuOpen) {
const buttonRect = buttonRef.current!.getBoundingClientRect();
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;
menu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<IconizedContextMenuOptionList>
<IconizedContextMenuOption label={_t("Video call (Jitsi)")} onClick={onJitsiClick} />
<IconizedContextMenuOption label={_t("Video call (Element Call)")} onClick={onElementClick} />
<IconizedContextMenuOption label={_t("Video call (%(brand)s)", { brand })} onClick={onElementClick} />
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
@ -207,6 +211,7 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
onClick={onClick}
title={_t("Video call")}
tooltip={tooltip ?? _t("Video call")}
alignment={Alignment.Bottom}
disabled={disabled || busy}
/>
{ menu }
@ -225,7 +230,9 @@ const CallButtons: FC<CallButtonsProps> = ({ room }) => {
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]);
const useElementCallExclusively = useMemo(() => SdkConfig.get("element_call").use_exclusively, []);
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively ?? DEFAULTS.element_call.use_exclusively;
}, []);
const hasLegacyCall = useEventEmitterState(
LegacyCallHandler.instance,
@ -318,6 +325,72 @@ const CallButtons: FC<CallButtonsProps> = ({ room }) => {
}
};
interface CallLayoutSelectorProps {
call: ElementCall;
}
const CallLayoutSelector: FC<CallLayoutSelectorProps> = ({ call }) => {
const layout = useLayout(call);
const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu();
const onClick = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
openMenu();
}, [openMenu]);
const onFreedomClick = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
call.setLayout(Layout.Tile);
}, [closeMenu, call]);
const onSpotlightClick = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
call.setLayout(Layout.Spotlight);
}, [closeMenu, call]);
let menu: JSX.Element | null = null;
if (menuOpen) {
const buttonRect = buttonRef.current!.getBoundingClientRect();
menu = <IconizedContextMenu
className="mx_RoomHeader_layoutMenu"
{...aboveLeftOf(buttonRect)}
onFinished={closeMenu}
>
<IconizedContextMenuOptionList>
<IconizedContextMenuRadio
iconClassName="mx_RoomHeader_freedomIcon"
label={_t("Freedom")}
active={layout === Layout.Tile}
onClick={onFreedomClick}
/>
<IconizedContextMenuRadio
iconClassName="mx_RoomHeader_spotlightIcon"
label={_t("Spotlight")}
active={layout === Layout.Spotlight}
onClick={onSpotlightClick}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
return <>
<AccessibleTooltipButton
inputRef={buttonRef}
className={classNames("mx_RoomHeader_button", {
"mx_RoomHeader_layoutButton--freedom": layout === Layout.Tile,
"mx_RoomHeader_layoutButton--spotlight": layout === Layout.Spotlight,
})}
onClick={onClick}
title={_t("Layout type")}
alignment={Alignment.Bottom}
key="layout"
/>
{ menu }
</>;
};
export interface ISearchInfo {
searchTerm: string;
searchScope: SearchScope;
@ -338,6 +411,8 @@ export interface IProps {
excludedRightPanelPhaseButtons?: Array<RightPanelPhases>;
showButtons?: boolean;
enableRoomOptionsMenu?: boolean;
viewingCall: boolean;
activeCall: Call | null;
}
interface IState {
@ -356,6 +431,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
private readonly client = this.props.room.client;
constructor(props: IProps, context: IState) {
super(props, context);
@ -367,14 +443,12 @@ export default class RoomHeader extends React.Component<IProps, IState> {
}
public componentDidMount() {
const cli = MatrixClientPeg.get();
cli.on(RoomStateEvent.Events, this.onRoomStateEvents);
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
}
public componentWillUnmount() {
const cli = MatrixClientPeg.get();
cli?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room);
notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate);
RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
@ -401,7 +475,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
this.forceUpdate();
}, 500, { leading: true, trailing: true });
private onContextMenuOpenClick = (ev: React.MouseEvent) => {
private onContextMenuOpenClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@ -412,56 +486,98 @@ export default class RoomHeader extends React.Component<IProps, IState> {
this.setState({ contextMenuPosition: undefined });
};
private renderButtons(): JSX.Element[] {
const buttons: JSX.Element[] = [];
private onHideCallClick = (ev: ButtonEvent) => {
ev.preventDefault();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
view_call: false,
metricsTrigger: undefined,
});
};
if (this.props.inRoom && !this.context.tombstone) {
buttons.push(<CallButtons key="calls" room={this.props.room} />);
private renderButtons(isVideoRoom: boolean): React.ReactNode {
const startButtons: JSX.Element[] = [];
if (!this.props.viewingCall && this.props.inRoom && !this.context.tombstone) {
startButtons.push(<CallButtons key="calls" room={this.props.room} />);
}
if (this.props.onForgetClick) {
const forgetButton = <AccessibleTooltipButton
if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) {
startButtons.push(<CallLayoutSelector call={this.props.activeCall} />);
}
if (!this.props.viewingCall && this.props.onForgetClick) {
startButtons.push(<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
onClick={this.props.onForgetClick}
title={_t("Forget room")}
alignment={Alignment.Bottom}
key="forget"
/>;
buttons.push(forgetButton);
/>);
}
if (this.props.onAppsClick) {
const appsButton = <AccessibleTooltipButton
if (!this.props.viewingCall && this.props.onAppsClick) {
startButtons.push(<AccessibleTooltipButton
className={classNames("mx_RoomHeader_button mx_RoomHeader_appsButton", {
mx_RoomHeader_appsButton_highlight: this.props.appsShown,
})}
onClick={this.props.onAppsClick}
title={this.props.appsShown ? _t("Hide Widgets") : _t("Show Widgets")}
alignment={Alignment.Bottom}
key="apps"
/>;
buttons.push(appsButton);
/>);
}
if (this.props.onSearchClick && this.props.inRoom) {
const searchButton = <AccessibleTooltipButton
if (!this.props.viewingCall && this.props.onSearchClick && this.props.inRoom) {
startButtons.push(<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_searchButton"
onClick={this.props.onSearchClick}
title={_t("Search")}
alignment={Alignment.Bottom}
key="search"
/>;
buttons.push(searchButton);
/>);
}
if (this.props.onInviteClick && this.props.inRoom) {
const inviteButton = <AccessibleTooltipButton
if (this.props.onInviteClick && (!this.props.viewingCall || isVideoRoom) && this.props.inRoom) {
startButtons.push(<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_inviteButton"
onClick={this.props.onInviteClick}
title={_t("Invite")}
alignment={Alignment.Bottom}
key="invite"
/>;
buttons.push(inviteButton);
/>);
}
return buttons;
const endButtons: JSX.Element[] = [];
if (this.props.viewingCall && !isVideoRoom) {
if (this.props.activeCall === null) {
endButtons.push(<AccessibleButton
className="mx_RoomHeader_button mx_RoomHeader_closeButton"
onClick={this.onHideCallClick}
title={_t("Close call")}
key="close"
/>);
} else {
endButtons.push(<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_minimiseButton"
onClick={this.onHideCallClick}
title={_t("View chat timeline")}
alignment={Alignment.Bottom}
key="minimise"
/>);
}
}
return <>
{ startButtons }
<RoomHeaderButtons
room={this.props.room}
excludedRightPanelPhaseButtons={this.props.excludedRightPanelPhaseButtons}
/>
{ endButtons }
</>;
}
private renderName(oobName: string) {
@ -480,7 +596,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
let settingsHint = false;
const members = this.props.room ? this.props.room.getJoinedMembers() : undefined;
if (members) {
if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) {
if (members.length === 1 && members[0].userId === this.client.credentials.userId) {
const nameEvent = this.props.room.currentState.getStateEvents('m.room.name', '');
if (!nameEvent || !nameEvent.getContent().name) {
settingsHint = true;
@ -505,6 +621,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
onClick={this.onContextMenuOpenClick}
isExpanded={!!this.state.contextMenuPosition}
title={_t("Room options")}
alignment={Alignment.Bottom}
>
{ roomName }
{ this.props.room && <div className="mx_RoomHeader_chevron" /> }
@ -519,6 +636,57 @@ export default class RoomHeader extends React.Component<IProps, IState> {
}
public render() {
const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room);
let roomAvatar: JSX.Element | null = null;
if (this.props.room) {
roomAvatar = <DecoratedRoomAvatar
room={this.props.room}
avatarSize={24}
oobData={this.props.oobData}
viewAvatarOnClick={true}
/>;
}
const icon = this.props.viewingCall
? <div className="mx_RoomHeader_icon mx_RoomHeader_icon_video" />
: this.props.e2eStatus
? <E2EIcon
className="mx_RoomHeader_icon"
status={this.props.e2eStatus}
tooltipAlignment={Alignment.Bottom}
/>
// If we're expecting an E2EE status to come in, but it hasn't
// yet been loaded, insert a blank div to reserve space
: this.client.isRoomEncrypted(this.props.room.roomId) && this.client.isCryptoEnabled()
? <div className="mx_RoomHeader_icon" />
: null;
const buttons = this.props.showButtons ? this.renderButtons(isVideoRoom) : null;
if (this.props.viewingCall && !isVideoRoom) {
return (
<header className="mx_RoomHeader light-panel">
<div
className="mx_RoomHeader_wrapper"
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
>
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
{ icon }
<div className="mx_RoomHeader_name mx_RoomHeader_name--textonly mx_RoomHeader_name--small">
{ _t("Video call") }
</div>
{ this.props.activeCall instanceof ElementCall && (
<CallDurationFromEvent mxEvent={this.props.activeCall.groupCall} />
) }
{ /* Empty topic element to fill out space */ }
<div className="mx_RoomHeader_topic" />
{ buttons }
</div>
</header>
);
}
let searchStatus: JSX.Element | null = null;
// don't display the search count until the search completes and
@ -543,29 +711,6 @@ export default class RoomHeader extends React.Component<IProps, IState> {
className="mx_RoomHeader_topic"
/>;
let roomAvatar: JSX.Element | null = null;
if (this.props.room) {
roomAvatar = <DecoratedRoomAvatar
room={this.props.room}
avatarSize={24}
oobData={this.props.oobData}
viewAvatarOnClick={true}
/>;
}
let buttons: JSX.Element | null = null;
if (this.props.showButtons) {
buttons = <React.Fragment>
<div className="mx_RoomHeader_buttons">
{ this.renderButtons() }
</div>
<RoomHeaderButtons room={this.props.room} excludedRightPanelPhaseButtons={this.props.excludedRightPanelPhaseButtons} />
</React.Fragment>;
}
const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;
const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room);
const viewLabs = () => defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
@ -581,7 +726,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
>
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
<div className="mx_RoomHeader_e2eIcon">{ e2eIcon }</div>
{ icon }
{ name }
{ searchStatus }
{ topicElement }

View file

@ -89,7 +89,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
call: CallStore.instance.get(this.props.room.roomId),
call: CallStore.instance.getCall(this.props.room.roomId),
// generatePreview() will return nothing if the user has previews disabled
messagePreview: "",
};
@ -159,7 +159,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
// Recalculate the call for this room, since it could've changed between
// construction and mounting
this.setState({ call: CallStore.instance.get(this.props.room.roomId) });
this.setState({ call: CallStore.instance.getCall(this.props.room.roomId) });
}
public componentWillUnmount() {

View file

@ -29,6 +29,7 @@ import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDi
import LogoutDialog from '../dialogs/LogoutDialog';
import DeviceTile from './devices/DeviceTile';
import SelectableDeviceTile from './devices/SelectableDeviceTile';
import { DeviceType } from '../../../utils/device/parseUserAgent';
interface IProps {
device: IMyDevice;
@ -153,9 +154,10 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
</AccessibleButton>
</React.Fragment>;
const deviceWithVerification = {
const extendedDevice = {
...this.props.device,
isVerified: this.props.verified,
deviceType: DeviceType.Unknown,
};
if (this.props.isOwnDevice) {
@ -163,7 +165,7 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
<div className="mx_DevicesPanel_deviceTrust">
<span className={"mx_DevicesPanel_icon mx_E2EIcon " + iconClass} />
</div>
<DeviceTile device={deviceWithVerification}>
<DeviceTile device={extendedDevice}>
{ buttons }
</DeviceTile>
</div>;
@ -171,7 +173,7 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
return (
<div className="mx_DevicesPanel_device">
<SelectableDeviceTile device={deviceWithVerification} onClick={this.onDeviceToggled} isSelected={this.props.selected}>
<SelectableDeviceTile device={extendedDevice} onClick={this.onDeviceToggled} isSelected={this.props.selected}>
{ buttons }
</SelectableDeviceTile>
</div>

View file

@ -24,10 +24,10 @@ import DeviceDetails from './DeviceDetails';
import DeviceExpandDetailsButton from './DeviceExpandDetailsButton';
import DeviceTile from './DeviceTile';
import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard';
import { DeviceWithVerification } from './types';
import { ExtendedDevice } from './types';
interface Props {
device?: DeviceWithVerification;
device?: ExtendedDevice;
isLoading: boolean;
isSigningOut: boolean;
localNotificationSettings?: LocalNotificationSettings | undefined;

View file

@ -22,10 +22,10 @@ import Field from '../../elements/Field';
import Spinner from '../../elements/Spinner';
import { Caption } from '../../typography/Caption';
import Heading from '../../typography/Heading';
import { DeviceWithVerification } from './types';
import { ExtendedDevice } from './types';
interface Props {
device: DeviceWithVerification;
device: ExtendedDevice;
saveDeviceName: (deviceName: string) => Promise<void>;
}

View file

@ -62,6 +62,7 @@ const DeviceDetails: React.FC<Props> = ({
id: 'session',
values: [
{ label: _t('Session ID'), value: device.device_id },
{ label: _t('Client'), value: device.client },
{
label: _t('Last activity'),
value: device.last_seen_ts && formatDate(new Date(device.last_seen_ts)),
@ -72,8 +73,8 @@ const DeviceDetails: React.FC<Props> = ({
id: 'application',
heading: _t('Application'),
values: [
{ label: _t('Name'), value: device.clientName },
{ label: _t('Version'), value: device.clientVersion },
{ label: _t('Name'), value: device.appName },
{ label: _t('Version'), value: device.appVersion },
{ label: _t('URL'), value: device.url },
],
},
@ -81,6 +82,8 @@ const DeviceDetails: React.FC<Props> = ({
id: 'device',
heading: _t('Device'),
values: [
{ label: _t('Model'), value: device.deviceModel },
{ label: _t('Operating system'), value: device.deviceOperatingSystem },
{ label: _t('IP address'), value: device.last_seen_ip },
],
},
@ -150,7 +153,7 @@ const DeviceDetails: React.FC<Props> = ({
checked={isPushNotificationsEnabled(pusher, localNotificationSettings)}
disabled={isCheckboxDisabled(pusher, localNotificationSettings)}
onChange={checked => setPushNotifications?.(device.device_id, checked)}
aria-label={_t("Toggle push notifications on this session.")}
title={_t("Toggle push notifications on this session.")}
data-testid='device-detail-push-notification-checkbox'
/>
<p className='mx_DeviceDetails_sectionHeading'>

View file

@ -21,16 +21,16 @@ import { _t } from "../../../../languageHandler";
import { formatDate, formatRelativeTime } from "../../../../DateUtils";
import Heading from "../../typography/Heading";
import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "./filter";
import { DeviceWithVerification } from "./types";
import { DeviceType } from "./DeviceType";
import { ExtendedDevice } from "./types";
import { DeviceTypeIcon } from "./DeviceTypeIcon";
export interface DeviceTileProps {
device: DeviceWithVerification;
device: ExtendedDevice;
isSelected?: boolean;
children?: React.ReactNode;
onClick?: () => void;
}
const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device }) => {
const DeviceTileName: React.FC<{ device: ExtendedDevice }> = ({ device }) => {
return <Heading size='h4'>
{ device.display_name || device.device_id }
</Heading>;
@ -48,7 +48,7 @@ const formatLastActivity = (timestamp: number, now = new Date().getTime()): stri
return formatRelativeTime(new Date(timestamp));
};
const getInactiveMetadata = (device: DeviceWithVerification): { id: string, value: React.ReactNode } | undefined => {
const getInactiveMetadata = (device: ExtendedDevice): { id: string, value: React.ReactNode } | undefined => {
const isInactive = isDeviceInactive(device);
if (!isInactive) {
@ -89,7 +89,11 @@ const DeviceTile: React.FC<DeviceTileProps> = ({
];
return <div className="mx_DeviceTile" data-testid={`device-tile-${device.device_id}`}>
<DeviceType isVerified={device.isVerified} isSelected={isSelected} />
<DeviceTypeIcon
isVerified={device.isVerified}
isSelected={isSelected}
deviceType={device.deviceType}
/>
<div className="mx_DeviceTile_info" onClick={onClick}>
<DeviceTileName device={device} />
<div className="mx_DeviceTile_metadata">

View file

@ -1,56 +0,0 @@
/*
Copyright 2022 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 React from 'react';
import classNames from 'classnames';
import { Icon as UnknownDeviceIcon } from '../../../../../res/img/element-icons/settings/unknown-device.svg';
import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg';
import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg';
import { _t } from '../../../../languageHandler';
import { DeviceWithVerification } from './types';
interface Props {
isVerified?: DeviceWithVerification['isVerified'];
isSelected?: boolean;
}
export const DeviceType: React.FC<Props> = ({ isVerified, isSelected }) => (
<div className={classNames('mx_DeviceType', {
mx_DeviceType_selected: isSelected,
})}
>
{ /* TODO(kerrya) all devices have an unknown type until PSG-650 */ }
<UnknownDeviceIcon
className='mx_DeviceType_deviceIcon'
role='img'
aria-label={_t('Unknown device type')}
/>
{
isVerified
? <VerifiedIcon
className={classNames('mx_DeviceType_verificationIcon', 'verified')}
role='img'
aria-label={_t('Verified')}
/>
: <UnverifiedIcon
className={classNames('mx_DeviceType_verificationIcon', 'unverified')}
role='img'
aria-label={_t('Unverified')}
/>
}
</div>);

View file

@ -0,0 +1,83 @@
/*
Copyright 2022 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 React from 'react';
import classNames from 'classnames';
import { Icon as UnknownDeviceIcon } from '../../../../../res/img/element-icons/settings/unknown-device.svg';
import { Icon as DesktopIcon } from '../../../../../res/img/element-icons/settings/desktop.svg';
import { Icon as WebIcon } from '../../../../../res/img/element-icons/settings/web.svg';
import { Icon as MobileIcon } from '../../../../../res/img/element-icons/settings/mobile.svg';
import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg';
import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg';
import { _t } from '../../../../languageHandler';
import { ExtendedDevice } from './types';
import { DeviceType } from '../../../../utils/device/parseUserAgent';
interface Props {
isVerified?: ExtendedDevice['isVerified'];
isSelected?: boolean;
deviceType?: DeviceType;
}
const deviceTypeIcon: Record<DeviceType, React.FC<React.SVGProps<SVGSVGElement>>> = {
[DeviceType.Desktop]: DesktopIcon,
[DeviceType.Mobile]: MobileIcon,
[DeviceType.Web]: WebIcon,
[DeviceType.Unknown]: UnknownDeviceIcon,
};
const deviceTypeLabel: Record<DeviceType, string> = {
[DeviceType.Desktop]: _t('Desktop session'),
[DeviceType.Mobile]: _t('Mobile session'),
[DeviceType.Web]: _t('Web session'),
[DeviceType.Unknown]: _t('Unknown session type'),
};
export const DeviceTypeIcon: React.FC<Props> = ({
isVerified,
isSelected,
deviceType,
}) => {
const Icon = deviceTypeIcon[deviceType] || deviceTypeIcon[DeviceType.Unknown];
const label = deviceTypeLabel[deviceType] || deviceTypeLabel[DeviceType.Unknown];
return (
<div className={classNames('mx_DeviceTypeIcon', {
mx_DeviceTypeIcon_selected: isSelected,
})}
>
<div className='mx_DeviceTypeIcon_deviceIconWrapper'>
<Icon
className='mx_DeviceTypeIcon_deviceIcon'
role='img'
aria-label={label}
/>
</div>
{
isVerified
? <VerifiedIcon
className={classNames('mx_DeviceTypeIcon_verificationIcon', 'verified')}
role='img'
aria-label={_t('Verified')}
/>
: <UnverifiedIcon
className={classNames('mx_DeviceTypeIcon_verificationIcon', 'unverified')}
role='img'
aria-label={_t('Unverified')}
/>
}
</div>);
};

View file

@ -21,11 +21,11 @@ import AccessibleButton from '../../elements/AccessibleButton';
import DeviceSecurityCard from './DeviceSecurityCard';
import {
DeviceSecurityVariation,
DeviceWithVerification,
ExtendedDevice,
} from './types';
interface Props {
device: DeviceWithVerification;
device: ExtendedDevice;
onVerifyDevice?: () => void;
}

View file

@ -33,7 +33,7 @@ import SelectableDeviceTile from './SelectableDeviceTile';
import {
DevicesDictionary,
DeviceSecurityVariation,
DeviceWithVerification,
ExtendedDevice,
} from './types';
import { DevicesState } from './useOwnDevices';
import FilteredDeviceListHeader from './FilteredDeviceListHeader';
@ -42,27 +42,27 @@ interface Props {
devices: DevicesDictionary;
pushers: IPusher[];
localNotificationSettings: Map<string, LocalNotificationSettings>;
expandedDeviceIds: DeviceWithVerification['device_id'][];
signingOutDeviceIds: DeviceWithVerification['device_id'][];
selectedDeviceIds: DeviceWithVerification['device_id'][];
expandedDeviceIds: ExtendedDevice['device_id'][];
signingOutDeviceIds: ExtendedDevice['device_id'][];
selectedDeviceIds: ExtendedDevice['device_id'][];
filter?: DeviceSecurityVariation;
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void;
onSignOutDevices: (deviceIds: DeviceWithVerification['device_id'][]) => void;
onDeviceExpandToggle: (deviceId: ExtendedDevice['device_id']) => void;
onSignOutDevices: (deviceIds: ExtendedDevice['device_id'][]) => void;
saveDeviceName: DevicesState['saveDeviceName'];
onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void;
onRequestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => void;
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
setSelectedDeviceIds: (deviceIds: DeviceWithVerification['device_id'][]) => void;
setSelectedDeviceIds: (deviceIds: ExtendedDevice['device_id'][]) => void;
supportsMSC3881?: boolean | undefined;
}
const isDeviceSelected = (
deviceId: DeviceWithVerification['device_id'],
selectedDeviceIds: DeviceWithVerification['device_id'][],
deviceId: ExtendedDevice['device_id'],
selectedDeviceIds: ExtendedDevice['device_id'][],
) => selectedDeviceIds.includes(deviceId);
// devices without timestamp metadata should be sorted last
const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) =>
const sortDevicesByLatestActivity = (left: ExtendedDevice, right: ExtendedDevice) =>
(right.last_seen_ts || 0) - (left.last_seen_ts || 0);
const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) =>
@ -149,7 +149,7 @@ const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
</div>;
const DeviceListItem: React.FC<{
device: DeviceWithVerification;
device: ExtendedDevice;
pusher?: IPusher | undefined;
localNotificationSettings?: LocalNotificationSettings | undefined;
isExpanded: boolean;
@ -227,11 +227,11 @@ export const FilteredDeviceList =
}: Props, ref: ForwardedRef<HTMLDivElement>) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);
function getPusherForDevice(device: DeviceWithVerification): IPusher | undefined {
function getPusherForDevice(device: ExtendedDevice): IPusher | undefined {
return pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === device.device_id);
}
const toggleSelection = (deviceId: DeviceWithVerification['device_id']): void => {
const toggleSelection = (deviceId: ExtendedDevice['device_id']): void => {
if (isDeviceSelected(deviceId, selectedDeviceIds)) {
// remove from selection
setSelectedDeviceIds(selectedDeviceIds.filter(id => id !== deviceId));

View file

@ -23,13 +23,13 @@ import DeviceSecurityCard from './DeviceSecurityCard';
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
import {
DeviceSecurityVariation,
DeviceWithVerification,
ExtendedDevice,
DevicesDictionary,
} from './types';
interface Props {
devices: DevicesDictionary;
currentDeviceId: DeviceWithVerification['device_id'];
currentDeviceId: ExtendedDevice['device_id'];
goToFilteredList: (filter: DeviceSecurityVariation) => void;
}
@ -38,7 +38,7 @@ const SecurityRecommendations: React.FC<Props> = ({
currentDeviceId,
goToFilteredList,
}) => {
const devicesArray = Object.values<DeviceWithVerification>(devices);
const devicesArray = Object.values<ExtendedDevice>(devices);
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(
devicesArray,

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { DeviceWithVerification, DeviceSecurityVariation } from "./types";
import { ExtendedDevice, DeviceSecurityVariation } from "./types";
type DeviceFilterCondition = (device: DeviceWithVerification) => boolean;
type DeviceFilterCondition = (device: ExtendedDevice) => boolean;
const MS_DAY = 24 * 60 * 60 * 1000;
export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days
@ -32,7 +32,7 @@ const filters: Record<DeviceSecurityVariation, DeviceFilterCondition> = {
};
export const filterDevicesBySecurityRecommendation = (
devices: DeviceWithVerification[],
devices: ExtendedDevice[],
securityVariations: DeviceSecurityVariation[],
) => {
const activeFilters = securityVariations.map(variation => filters[variation]);

View file

@ -16,14 +16,17 @@ limitations under the License.
import { IMyDevice } from "matrix-js-sdk/src/matrix";
import { ExtendedDeviceInformation } from "../../../../utils/device/parseUserAgent";
export type DeviceWithVerification = IMyDevice & { isVerified: boolean | null };
export type ExtendedDeviceInfo = {
clientName?: string;
clientVersion?: string;
export type ExtendedDeviceAppInfo = {
// eg Element Web
appName?: string;
appVersion?: string;
url?: string;
};
export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceInfo;
export type DevicesDictionary = Record<DeviceWithVerification['device_id'], ExtendedDevice>;
export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceAppInfo & ExtendedDeviceInformation;
export type DevicesDictionary = Record<ExtendedDevice['device_id'], ExtendedDevice>;
export enum DeviceSecurityVariation {
Verified = 'Verified',

View file

@ -24,6 +24,7 @@ import {
MatrixEvent,
PUSHER_DEVICE_ID,
PUSHER_ENABLED,
UNSTABLE_MSC3852_LAST_SEEN_UA,
} from "matrix-js-sdk/src/matrix";
import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
@ -34,8 +35,9 @@ import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifi
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { _t } from "../../../../languageHandler";
import { getDeviceClientInformation } from "../../../../utils/device/clientInformation";
import { DevicesDictionary, DeviceWithVerification, ExtendedDeviceInfo } from "./types";
import { DevicesDictionary, ExtendedDevice, ExtendedDeviceAppInfo } from "./types";
import { useEventEmitter } from "../../../../hooks/useEventEmitter";
import { parseUserAgent } from "../../../../utils/device/parseUserAgent";
const isDeviceVerified = (
matrixClient: MatrixClient,
@ -63,12 +65,12 @@ const isDeviceVerified = (
}
};
const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceInfo => {
const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceAppInfo => {
const { name, version, url } = getDeviceClientInformation(matrixClient, device.device_id);
return {
clientName: name,
clientVersion: version,
appName: name,
appVersion: version,
url,
};
};
@ -87,6 +89,7 @@ const fetchDevicesWithVerification = async (
...device,
isVerified: isDeviceVerified(matrixClient, crossSigningInfo, device),
...parseDeviceExtendedInformation(matrixClient, device),
...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]),
},
}), {});
@ -104,10 +107,10 @@ export type DevicesState = {
currentDeviceId: string;
isLoadingDeviceList: boolean;
// not provided when current session cannot request verification
requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise<VerificationRequest>;
requestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => Promise<VerificationRequest>;
refreshDevices: () => Promise<void>;
saveDeviceName: (deviceId: DeviceWithVerification['device_id'], deviceName: string) => Promise<void>;
setPushNotifications: (deviceId: DeviceWithVerification['device_id'], enabled: boolean) => Promise<void>;
saveDeviceName: (deviceId: ExtendedDevice['device_id'], deviceName: string) => Promise<void>;
setPushNotifications: (deviceId: ExtendedDevice['device_id'], enabled: boolean) => Promise<void>;
error?: OwnDevicesError;
supportsMSC3881?: boolean | undefined;
};
@ -189,7 +192,7 @@ export const useOwnDevices = (): DevicesState => {
const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified;
const requestDeviceVerification = isCurrentDeviceVerified && userId
? async (deviceId: DeviceWithVerification['device_id']) => {
? async (deviceId: ExtendedDevice['device_id']) => {
return await matrixClient.requestVerification(
userId,
[deviceId],
@ -198,7 +201,7 @@ export const useOwnDevices = (): DevicesState => {
: undefined;
const saveDeviceName = useCallback(
async (deviceId: DeviceWithVerification['device_id'], deviceName: string): Promise<void> => {
async (deviceId: ExtendedDevice['device_id'], deviceName: string): Promise<void> => {
const device = devices[deviceId];
// no change
@ -219,7 +222,7 @@ export const useOwnDevices = (): DevicesState => {
}, [matrixClient, devices, refreshDevices]);
const setPushNotifications = useCallback(
async (deviceId: DeviceWithVerification['device_id'], enabled: boolean): Promise<void> => {
async (deviceId: ExtendedDevice['device_id'], enabled: boolean): Promise<void> => {
try {
const pusher = pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === deviceId);
if (pusher) {

View file

@ -31,6 +31,8 @@ import PowerSelector from "../../../elements/PowerSelector";
import SettingsFieldset from '../../SettingsFieldset';
import SettingsStore from "../../../../../settings/SettingsStore";
import { VoiceBroadcastInfoEventType } from '../../../../../voice-broadcast';
import { ElementCall } from "../../../../../models/Call";
import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig";
interface IEventShowOpts {
isState?: boolean;
@ -60,6 +62,10 @@ const plEventsToShow: Record<string, IEventShowOpts> = {
[EventType.Reaction]: { isState: false, hideForSpace: true },
[EventType.RoomRedaction]: { isState: false, hideForSpace: true },
// MSC3401: Native Group VoIP signaling
[ElementCall.CALL_EVENT_TYPE.name]: { isState: true, hideForSpace: true },
[ElementCall.MEMBER_EVENT_TYPE.name]: { isState: true, hideForSpace: true },
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
"im.vector.modular.widgets": { isState: true, hideForSpace: true },
[VoiceBroadcastInfoEventType]: { isState: true, hideForSpace: true },
@ -252,6 +258,11 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
if (SettingsStore.getValue("feature_pinning")) {
plEventsToLabels[EventType.RoomPinnedEvents] = _td("Manage pinned events");
}
// MSC3401: Native Group VoIP signaling
if (SettingsStore.getValue("feature_group_calls")) {
plEventsToLabels[ElementCall.CALL_EVENT_TYPE.name] = _td("Start %(brand)s calls");
plEventsToLabels[ElementCall.MEMBER_EVENT_TYPE.name] = _td("Join %(brand)s calls");
}
const powerLevelDescriptors: Record<string, IPowerLevelDescriptor> = {
"users_default": {
@ -435,7 +446,8 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
let label = plEventsToLabels[eventType];
if (label) {
label = _t(label);
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;
label = _t(label, { brand });
} else {
label = _t("Send %(eventType)s events", { eventType });
}

View file

@ -0,0 +1,99 @@
/*
Copyright 2022 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 React, { useCallback, useMemo, useState } from 'react';
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from "../../../../../languageHandler";
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import SettingsSubsection from "../../shared/SettingsSubsection";
import SettingsTab from "../SettingsTab";
import { ElementCall } from "../../../../../models/Call";
import { useRoomState } from "../../../../../hooks/useRoomState";
import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig";
interface ElementCallSwitchProps {
roomId: string;
}
const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ roomId }) => {
const room = useMemo(() => MatrixClientPeg.get().getRoom(roomId), [roomId]);
const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]);
const [content, events, maySend] = useRoomState(room, useCallback((state) => {
const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
return [
content ?? {},
content?.["events"] ?? {},
state?.maySendStateEvent(EventType.RoomPowerLevels, MatrixClientPeg.get().getUserId()),
];
}, []));
const [elementCallEnabled, setElementCallEnabled] = useState<boolean>(() => {
return events[ElementCall.MEMBER_EVENT_TYPE.name] === 0;
});
const onChange = useCallback((enabled: boolean): void => {
setElementCallEnabled(enabled);
if (enabled) {
const userLevel = events[EventType.RoomMessage] ?? content.users_default ?? 0;
const moderatorLevel = content.kick ?? 50;
events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel;
events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel;
} else {
const adminLevel = events[EventType.RoomPowerLevels] ?? content.state_default ?? 100;
events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel;
events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel;
}
MatrixClientPeg.get().sendStateEvent(roomId, EventType.RoomPowerLevels, {
"events": events,
...content,
});
}, [roomId, content, events, isPublic]);
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;
return <LabelledToggleSwitch
data-testid="element-call-switch"
label={_t("Enable %(brand)s as an additional calling option in this room", { brand })}
caption={_t(
"%(brand)s is end-to-end encrypted, " +
"but is currently limited to smaller numbers of users.",
{ brand },
)}
value={elementCallEnabled}
onChange={onChange}
disabled={!maySend}
tooltip={_t("You do not have sufficient permissions to change this.")}
/>;
};
interface Props {
roomId: string;
}
export const VoipRoomSettingsTab: React.FC<Props> = ({ roomId }) => {
return <SettingsTab heading={_t("Voice & Video")}>
<SettingsSubsection heading={_t("Call type")}>
<ElementCallSwitch roomId={roomId} />
</SettingsSubsection>
</SettingsTab>;
};

View file

@ -214,7 +214,10 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
{ _t("Share your activity and status with others.") }
</span>
<SettingsFlag
disabled={!this.state.disablingReadReceiptsSupported}
disabled={
!this.state.disablingReadReceiptsSupported
&& SettingsStore.getValue("sendReadReceipts") // Make sure the feature can always be enabled
}
disabledDescription={_t("Your server doesn't support disabling sending read receipts.")}
name="sendReadReceipts"
level={SettingLevel.ACCOUNT}

View file

@ -29,7 +29,7 @@ import { useOwnDevices } from '../../devices/useOwnDevices';
import { FilteredDeviceList } from '../../devices/FilteredDeviceList';
import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
import SecurityRecommendations from '../../devices/SecurityRecommendations';
import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/types';
import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types';
import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices';
import SettingsTab from '../SettingsTab';
@ -38,10 +38,10 @@ const useSignOut = (
onSignoutResolvedCallback: () => Promise<void>,
): {
onSignOutCurrentDevice: () => void;
onSignOutOtherDevices: (deviceIds: DeviceWithVerification['device_id'][]) => Promise<void>;
signingOutDeviceIds: DeviceWithVerification['device_id'][];
onSignOutOtherDevices: (deviceIds: ExtendedDevice['device_id'][]) => Promise<void>;
signingOutDeviceIds: ExtendedDevice['device_id'][];
} => {
const [signingOutDeviceIds, setSigningOutDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]);
const [signingOutDeviceIds, setSigningOutDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const onSignOutCurrentDevice = () => {
Modal.createDialog(
@ -53,7 +53,7 @@ const useSignOut = (
);
};
const onSignOutOtherDevices = async (deviceIds: DeviceWithVerification['device_id'][]) => {
const onSignOutOtherDevices = async (deviceIds: ExtendedDevice['device_id'][]) => {
if (!deviceIds.length) {
return;
}
@ -96,8 +96,8 @@ const SessionManagerTab: React.FC = () => {
supportsMSC3881,
} = useOwnDevices();
const [filter, setFilter] = useState<DeviceSecurityVariation>();
const [expandedDeviceIds, setExpandedDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]);
const [expandedDeviceIds, setExpandedDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const filteredDeviceListRef = useRef<HTMLDivElement>(null);
const scrollIntoViewTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
@ -105,7 +105,7 @@ const SessionManagerTab: React.FC = () => {
const userId = matrixClient.getUserId();
const currentUserMember = userId && matrixClient.getUser(userId) || undefined;
const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => {
const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => {
if (expandedDeviceIds.includes(deviceId)) {
setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId));
} else {
@ -136,7 +136,7 @@ const SessionManagerTab: React.FC = () => {
);
};
const onTriggerDeviceVerification = useCallback((deviceId: DeviceWithVerification['device_id']) => {
const onTriggerDeviceVerification = useCallback((deviceId: ExtendedDevice['device_id']) => {
if (!requestDeviceVerification) {
return;
}

View file

@ -22,7 +22,7 @@ import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { ConnectionState } from "../../../models/Call";
import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call";
import { useCall, useConnectionState, useParticipants } from "../../../hooks/useCall";
import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AppTile from "../elements/AppTile";
import { _t } from "../../../languageHandler";
@ -35,7 +35,7 @@ import IconizedContextMenu, {
} from "../context_menus/IconizedContextMenu";
import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import { Alignment } from "../elements/Tooltip";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { ButtonEvent } from "../elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import FacePile from "../elements/FacePile";
import MemberAvatar from "../avatars/MemberAvatar";
@ -110,10 +110,11 @@ const MAX_FACES = 8;
interface LobbyProps {
room: Room;
connect: () => Promise<void>;
joinCallButtonDisabledTooltip?: string;
children?: ReactNode;
}
export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, connect, children }) => {
const [connecting, setConnecting] = useState(false);
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
const videoRef = useRef<HTMLVideoElement>(null);
@ -233,14 +234,15 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
/>
</div>
</div>
<AccessibleButton
<AccessibleTooltipButton
className="mx_CallView_connectButton"
kind="primary"
disabled={connecting}
disabled={connecting || Boolean(joinCallButtonDisabledTooltip)}
onClick={onConnectClick}
>
{ _t("Join") }
</AccessibleButton>
title={_t("Join")}
label={_t("Join")}
tooltip={connecting ? _t("Connecting") : joinCallButtonDisabledTooltip}
/>
</div>;
};
@ -321,6 +323,7 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
const cli = useContext(MatrixClientContext);
const connected = isConnected(useConnectionState(call));
const participants = useParticipants(call);
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
const connect = useCallback(async () => {
// Disconnect from any other active calls first, since we don't yet support holding
@ -344,7 +347,13 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
</div>;
}
lobby = <Lobby room={room} connect={connect}>{ facePile }</Lobby>;
lobby = <Lobby
room={room}
connect={connect}
joinCallButtonDisabledTooltip={joinCallButtonDisabledTooltip}
>
{ facePile }
</Lobby>;
}
return <div className="mx_CallView">

View file

@ -32,7 +32,7 @@ const LegacyCallViewHeaderControls: React.FC<LegacyCallControlsProps> = ({ onExp
{ onMaximize && <AccessibleTooltipButton
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_fullscreen"
onClick={onMaximize}
title={_t("Fill Screen")}
title={_t("Fill screen")}
/> }
{ onPin && <AccessibleTooltipButton
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_pin"

View file

@ -201,7 +201,7 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
};
return (
<div
<aside
className={this.props.className}
style={style}
ref={this.callViewWrapper}
@ -211,7 +211,7 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
onStartMoving: this.onStartMoving,
onResize: this.onResize,
}) }
</div>
</aside>
);
}
}

View file

@ -24,7 +24,6 @@ import LegacyCallView from "./LegacyCallView";
import { RoomViewStore } from '../../../stores/RoomViewStore';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler';
import PersistentApp from "../elements/PersistentApp";
import SettingsStore from "../../../settings/SettingsStore";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import PictureInPictureDragger from './PictureInPictureDragger';
import dis from '../../../dispatcher/dispatcher';
@ -35,6 +34,7 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/Activ
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { UPDATE_EVENT } from '../../../stores/AsyncStore';
import { CallStore } from "../../../stores/CallStore";
const SHOW_CALL_IN_STATES = [
CallState.Connected,
@ -116,7 +116,6 @@ function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall
*/
export default class PipView extends React.Component<IProps, IState> {
private settingsWatcherRef: string;
private movePersistedElement = createRef<() => void>();
constructor(props: IProps) {
@ -157,7 +156,6 @@ export default class PipView extends React.Component<IProps, IState> {
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls);
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate);
SettingsStore.unwatchSetting(this.settingsWatcherRef);
const room = MatrixClientPeg.get().getRoom(this.state.viewedRoomId);
if (room) {
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls);
@ -278,6 +276,14 @@ export default class PipView extends React.Component<IProps, IState> {
});
};
private onViewCall = (): void =>
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.state.persistentRoomId,
view_call: true,
metricsTrigger: undefined,
});
// Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId
public updateShowWidgetInPip(
persistentWidgetId = this.state.persistentWidgetId,
@ -323,18 +329,19 @@ export default class PipView extends React.Component<IProps, IState> {
mx_LegacyCallView_large: !pipMode,
});
const roomId = this.state.persistentRoomId;
const roomForWidget = MatrixClientPeg.get().getRoom(roomId);
const roomForWidget = MatrixClientPeg.get().getRoom(roomId)!;
const viewingCallRoom = this.state.viewedRoomId === roomId;
const isCall = CallStore.instance.getActiveCall(roomId) !== null;
pipContent = ({ onStartMoving, _onResize }) =>
pipContent = ({ onStartMoving }) =>
<div className={pipViewClasses}>
<LegacyCallViewHeader
onPipMouseDown={(event) => { onStartMoving(event); this.onStartMoving.bind(this)(); }}
pipMode={pipMode}
callRooms={[roomForWidget]}
onExpand={!viewingCallRoom && this.onExpand}
onPin={viewingCallRoom && this.onPin}
onMaximize={viewingCallRoom && this.onMaximize}
onExpand={!isCall && !viewingCallRoom ? this.onExpand : undefined}
onPin={!isCall && viewingCallRoom ? this.onPin : undefined}
onMaximize={isCall ? this.onViewCall : viewingCallRoom ? this.onMaximize : undefined}
/>
<PersistentApp
persistentWidgetId={this.state.persistentWidgetId}