Merge branch 'develop' into travis/widget-api

This commit is contained in:
Travis Ralston 2020-09-29 13:18:18 -06:00
commit 14766e24b8
20 changed files with 351 additions and 81 deletions

View file

@ -141,6 +141,7 @@
@import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MFileBody.scss";
@import "./views/messages/_MImageBody.scss"; @import "./views/messages/_MImageBody.scss";
@import "./views/messages/_MJitsiWidgetEvent.scss";
@import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MNoticeBody.scss";
@import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MStickerBody.scss";
@import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MTextBody.scss";

View file

@ -0,0 +1,55 @@
/*
Copyright 2020 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.
*/
.mx_MJitsiWidgetEvent {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) min-content;
&::before {
grid-column: 1;
grid-row: 1 / 3;
width: 16px;
height: 16px;
content: "";
top: 0;
bottom: 0;
left: 0;
right: 0;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
background-color: $composer-e2e-icon-color; // XXX: Variable abuse
margin-top: 4px;
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
.mx_MJitsiWidgetEvent_title {
font-weight: 600;
font-size: $font-15px;
grid-column: 2;
grid-row: 1;
}
.mx_MJitsiWidgetEvent_subtitle {
grid-column: 2;
grid-row: 2;
}
.mx_MJitsiWidgetEvent_title,
.mx_MJitsiWidgetEvent_subtitle {
overflow-wrap: break-word;
}
}

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
$MiniAppTileHeight: 114px; $MiniAppTileHeight: 200px;
.mx_AppsDrawer { .mx_AppsDrawer {
margin: 5px 5px 5px 18px; margin: 5px 5px 5px 18px;
@ -220,9 +220,10 @@ $MiniAppTileHeight: 114px;
} }
.mx_AppTileBody_mini { .mx_AppTileBody_mini {
height: 112px; height: $MiniAppTileHeight;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
border-radius: 8px;
} }
.mx_AppTile .mx_AppTileBody, .mx_AppTile .mx_AppTileBody,

View file

@ -217,7 +217,7 @@ limitations under the License.
} }
} }
&.mx_MessageComposer_hangup::before { &.mx_MessageComposer_hangup:not(.mx_AccessibleButton_disabled)::before {
background-color: $warning-color; background-color: $warning-color;
} }
} }

View file

@ -23,9 +23,16 @@ limitations under the License.
z-index: 100; z-index: 100;
box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
cursor: pointer; // Disable pointer events for Jitsi widgets to function. Direct
// calls have their own cursor and behaviour, but we need to make
// sure the cursor hits the iframe for Jitsi which will be at a
// different level.
pointer-events: none;
.mx_CallPreview { .mx_CallPreview {
pointer-events: initial; // restore pointer events so the user can leave/interact
cursor: pointer;
.mx_VideoView { .mx_VideoView {
width: 350px; width: 350px;
} }
@ -37,7 +44,7 @@ limitations under the License.
} }
.mx_AppTile_persistedWrapper div { .mx_AppTile_persistedWrapper div {
min-width: 300px; min-width: 350px;
} }
.mx_IncomingCallBox { .mx_IncomingCallBox {
@ -45,6 +52,9 @@ limitations under the License.
background-color: $primary-bg-color; background-color: $primary-bg-color;
padding: 8px; padding: 8px;
pointer-events: initial; // restore pointer events so the user can accept/decline
cursor: pointer;
.mx_IncomingCallBox_CallerInfo { .mx_IncomingCallBox_CallerInfo {
display: flex; display: flex;
direction: row; direction: row;

View file

@ -74,6 +74,8 @@ import {base32} from "rfc4648";
import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import WidgetStore from "./stores/WidgetStore";
import ActiveWidgetStore from "./stores/ActiveWidgetStore";
// until we ts-ify the js-sdk voip code // until we ts-ify the js-sdk voip code
type Call = any; type Call = any;
@ -351,6 +353,14 @@ export default class CallHandler {
console.info("Place conference call in %s", payload.room_id); console.info("Place conference call in %s", payload.room_id);
this.startCallApp(payload.room_id, payload.type); this.startCallApp(payload.room_id, payload.type);
break; break;
case 'end_conference':
console.info("Terminating conference call in %s", payload.room_id);
this.terminateCallApp(payload.room_id);
break;
case 'hangup_conference':
console.info("Leaving conference call in %s", payload.room_id);
this.hangupCallApp(payload.room_id);
break;
case 'incoming_call': case 'incoming_call':
{ {
if (this.getAnyActiveCall()) { if (this.getAnyActiveCall()) {
@ -398,10 +408,12 @@ export default class CallHandler {
show: true, show: true,
}); });
// prevent double clicking the call button
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
const hasJitsi = currentJitsiWidgets.length > 0
if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) { || WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI);
if (hasJitsi) {
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
title: _t('Call in Progress'), title: _t('Call in Progress'),
description: _t('A call is currently being placed!'), description: _t('A call is currently being placed!'),
@ -409,33 +421,6 @@ export default class CallHandler {
return; return;
} }
if (currentJitsiWidgets.length > 0) {
console.warn(
"Refusing to start conference call widget in " + roomId +
" a conference call widget is already present",
);
if (WidgetUtils.canUserModifyWidgets(roomId)) {
Modal.createTrackedDialog('Already have Jitsi Widget', '', QuestionDialog, {
title: _t('End Call'),
description: _t('Remove the group call from the room?'),
button: _t('End Call'),
cancelButton: _t('Cancel'),
onFinished: (endCall) => {
if (endCall) {
WidgetUtils.setRoomWidget(roomId, currentJitsiWidgets[0].getContent()['id']);
}
},
});
} else {
Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, {
title: _t('Call in Progress'),
description: _t("You don't have permission to remove the call from the room"),
});
}
return;
}
const jitsiDomain = Jitsi.getInstance().preferredDomain; const jitsiDomain = Jitsi.getInstance().preferredDomain;
const jitsiAuth = await Jitsi.getInstance().getJitsiAuth(); const jitsiAuth = await Jitsi.getInstance().getJitsiAuth();
let confId; let confId;
@ -484,4 +469,38 @@ export default class CallHandler {
console.error(e); console.error(e);
}); });
} }
private terminateCallApp(roomId: string) {
Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, {
hasCancelButton: true,
title: _t("End conference"),
description: _t("This will end the conference for everyone. Continue?"),
button: _t("End conference"),
onFinished: (proceed) => {
if (!proceed) return;
// We'll just obliterate them all. There should only ever be one, but might as well
// be safe.
const roomInfo = WidgetStore.instance.getRoom(roomId);
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
jitsiWidgets.forEach(w => {
// setting invalid content removes it
WidgetUtils.setRoomWidget(roomId, w.id);
});
},
});
}
private hangupCallApp(roomId: string) {
const roomInfo = WidgetStore.instance.getRoom(roomId);
if (!roomInfo) return; // "should never happen" clauses go here
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
jitsiWidgets.forEach(w => {
const messaging = ActiveWidgetStore.getWidgetMessaging(w.id);
if (!messaging) return; // more "should never happen" words
messaging.hangup();
});
}
} }

View file

@ -18,7 +18,6 @@ import { _t } from './languageHandler';
import * as Roles from './Roles'; import * as Roles from './Roles';
import {isValid3pidInvite} from "./RoomInvite"; import {isValid3pidInvite} from "./RoomInvite";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import {WidgetType} from "./widgets/WidgetType";
import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
function textForMemberEvent(ev) { function textForMemberEvent(ev) {
@ -464,10 +463,6 @@ function textForWidgetEvent(event) {
const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent(); const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent();
const {name, type, url} = event.getContent() || {}; const {name, type, url} = event.getContent() || {};
if (WidgetType.JITSI.matches(type) || WidgetType.JITSI.matches(prevType)) {
return textForJitsiWidgetEvent(event, senderName, url, prevUrl);
}
let widgetName = name || prevName || type || prevType || ''; let widgetName = name || prevName || type || prevType || '';
// Apply sentence case to widget name // Apply sentence case to widget name
if (widgetName && widgetName.length > 0) { if (widgetName && widgetName.length > 0) {
@ -493,24 +488,6 @@ function textForWidgetEvent(event) {
} }
} }
function textForJitsiWidgetEvent(event, senderName, url, prevUrl) {
if (url) {
if (prevUrl) {
return _t('Group call modified by %(senderName)s', {
senderName,
});
} else {
return _t('Group call started by %(senderName)s', {
senderName,
});
}
} else {
return _t('Group call ended by %(senderName)s', {
senderName,
});
}
}
function textForMjolnirEvent(event) { function textForMjolnirEvent(event) {
const senderName = event.getSender(); const senderName = event.getSender();
const {entity: prevEntity} = event.getPrevContent(); const {entity: prevEntity} = event.getPrevContent();

View file

@ -107,6 +107,17 @@ export default class WidgetMessaging {
}); });
} }
/**
* Tells the widget to hang up on its call.
* @returns {Promise<*>} Resolves when the widget has acknowledged the message.
*/
hangup() {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: KnownWidgetActions.Hangup,
});
}
/** /**
* Request a screenshot from a widget * Request a screenshot from a widget
* @return {Promise} To be resolved with screenshot data when it has been generated * @return {Promise} To be resolved with screenshot data when it has been generated

View file

@ -117,7 +117,9 @@ export default class SetPasswordDialog extends React.Component {
autoFocusNewPasswordInput={true} autoFocusNewPasswordInput={true}
shouldAskForEmail={true} shouldAskForEmail={true}
onError={this._onPasswordChangeError} onError={this._onPasswordChangeError}
onFinished={this._onPasswordChanged} /> onFinished={this._onPasswordChanged}
buttonLabel={_t("Set Password")}
/>
<div className="error"> <div className="error">
{ this.state.error } { this.state.error }
</div> </div>

View file

@ -82,6 +82,7 @@ export default class PersistentApp extends React.Component {
showDelete={false} showDelete={false}
showMinimise={false} showMinimise={false}
miniMode={true} miniMode={true}
showMenubar={false}
/>; />;
} }
} }

View file

@ -0,0 +1,76 @@
/*
Copyright 2020 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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
interface IProps {
mxEvent: MatrixEvent;
}
export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
constructor(props) {
super(props);
}
render() {
const url = this.props.mxEvent.getContent()['url'];
const prevUrl = this.props.mxEvent.getPrevContent()['url'];
const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
let joinCopy = _t('Join the conference at the top of this room');
if (!WidgetStore.instance.isPinned(this.props.mxEvent.getStateKey())) {
joinCopy = _t('Join the conference from the room information card on the right');
}
if (!url) {
// removed
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t('Video conference ended by %(senderName)s', {senderName})}
</div>
</div>
);
} else if (prevUrl) {
// modified
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t('Video conference updated by %(senderName)s', {senderName})}
</div>
<div className='mx_MJitsiWidgetEvent_subtitle'>
{joinCopy}
</div>
</div>
);
} else {
// assume added
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t("Video conference started by %(senderName)s", {senderName})}
</div>
<div className='mx_MJitsiWidgetEvent_subtitle'>
{joinCopy}
</div>
</div>
);
}
}
}

View file

@ -619,13 +619,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
private onFormatAction = (action: Formatting) => { private onFormatAction = (action: Formatting) => {
const range = getRangeForSelection( const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
this.editorRef.current, // trim the range as we want it to exclude leading/trailing spaces
this.props.model, range.trim();
document.getSelection());
if (range.length === 0) { if (range.length === 0) {
return; return;
} }
this.historyManager.ensureLastChangesPushed(this.props.model); this.historyManager.ensureLastChangesPushed(this.props.model);
this.modifiedFlag = true; this.modifiedFlag = true;
switch (action) { switch (action) {

View file

@ -34,6 +34,7 @@ import * as ObjectUtils from "../../../ObjectUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon"; import {E2E_STATE} from "./E2EIcon";
import {toRem} from "../../../utils/units"; import {toRem} from "../../../utils/units";
import {WidgetType} from "../../../widgets/WidgetType";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
const eventTileTypes = { const eventTileTypes = {
@ -111,6 +112,19 @@ export function getHandlerTile(ev) {
} }
} }
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
if (type === "im.vector.modular.widgets") {
let type = ev.getContent()['type'];
if (!type) {
// deleted/invalid widget - try the past widget type
type = ev.getPrevContent()['type'];
}
if (WidgetType.JITSI.matches(type)) {
return "messages.MJitsiWidgetEvent";
}
}
return ev.isState() ? stateEventTileTypes[type] : eventTileTypes[type]; return ev.isState() ? stateEventTileTypes[type] : eventTileTypes[type];
} }
@ -627,16 +641,18 @@ export default class EventTile extends React.Component {
const msgtype = content.msgtype; const msgtype = content.msgtype;
const eventType = this.props.mxEvent.getType(); const eventType = this.props.mxEvent.getType();
let tileHandler = getHandlerTile(this.props.mxEvent);
// Info messages are basically information about commands processed on a room // Info messages are basically information about commands processed on a room
const isBubbleMessage = eventType.startsWith("m.key.verification") || const isBubbleMessage = eventType.startsWith("m.key.verification") ||
(eventType === "m.room.message" && msgtype && msgtype.startsWith("m.key.verification")) || (eventType === "m.room.message" && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === "m.room.encryption"); (eventType === "m.room.encryption") ||
(tileHandler === "messages.MJitsiWidgetEvent");
let isInfoMessage = ( let isInfoMessage = (
!isBubbleMessage && eventType !== 'm.room.message' && !isBubbleMessage && eventType !== 'm.room.message' &&
eventType !== 'm.sticker' && eventType !== 'm.room.create' eventType !== 'm.sticker' && eventType !== 'm.room.create'
); );
let tileHandler = getHandlerTile(this.props.mxEvent);
// If we're showing hidden events in the timeline, we should use the // If we're showing hidden events in the timeline, we should use the
// source tile when there's no regular tile for an event and also for // source tile when there's no regular tile for an event and also for
// replace relations (which otherwise would display as a confusing // replace relations (which otherwise would display as a confusing

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -32,6 +33,10 @@ import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview"; import ReplyPreview from "./ReplyPreview";
import {UIFeature} from "../../../settings/UIFeature"; import {UIFeature} from "../../../settings/UIFeature";
import WidgetStore from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
function ComposerAvatar(props) { function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@ -85,8 +90,15 @@ VideoCallButton.propTypes = {
}; };
function HangupButton(props) { function HangupButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const onHangupClick = () => { const onHangupClick = () => {
if (props.isConference) {
dis.dispatch({
action: props.canEndConference ? 'end_conference' : 'hangup_conference',
room_id: props.roomId,
});
return;
}
const call = CallHandler.sharedInstance().getCallForRoom(props.roomId); const call = CallHandler.sharedInstance().getCallForRoom(props.roomId);
if (!call) { if (!call) {
return; return;
@ -98,14 +110,28 @@ function HangupButton(props) {
room_id: call.roomId, room_id: call.roomId,
}); });
}; };
return (<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
let tooltip = _t("Hangup");
if (props.isConference && props.canEndConference) {
tooltip = _t("End conference");
}
const canLeaveConference = !props.isConference ? true : props.isInConference;
return (
<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_hangup"
onClick={onHangupClick} onClick={onHangupClick}
title={_t('Hangup')} title={tooltip}
/>); disabled={!canLeaveConference}
/>
);
} }
HangupButton.propTypes = { HangupButton.propTypes = {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
isConference: PropTypes.bool.isRequired,
canEndConference: PropTypes.bool,
isInConference: PropTypes.bool,
}; };
const EmojiButton = ({addEmoji}) => { const EmojiButton = ({addEmoji}) => {
@ -226,12 +252,17 @@ export default class MessageComposer extends React.Component {
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this); this._onTombstoneClick = this._onTombstoneClick.bind(this);
this.renderPlaceholderText = this.renderPlaceholderText.bind(this); this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.on('update', this._onActiveWidgetUpdate);
this._dispatcherRef = null; this._dispatcherRef = null;
this.state = { this.state = {
isQuoting: Boolean(RoomViewStore.getQuotingEvent()), isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
tombstone: this._getRoomTombstone(), tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(), canSendMessages: this.props.room.maySendMessage(),
showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"), showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"),
hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room),
joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room),
}; };
} }
@ -247,6 +278,14 @@ export default class MessageComposer extends React.Component {
} }
}; };
_onWidgetUpdate = () => {
this.setState({hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room)});
};
_onActiveWidgetUpdate = () => {
this.setState({joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room)});
};
componentDidMount() { componentDidMount() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
@ -277,6 +316,8 @@ export default class MessageComposer extends React.Component {
if (this._roomStoreToken) { if (this._roomStoreToken) {
this._roomStoreToken.remove(); this._roomStoreToken.remove();
} }
WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate);
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
} }
@ -392,9 +433,19 @@ export default class MessageComposer extends React.Component {
} }
if (this.state.showCallButtons) { if (this.state.showCallButtons) {
if (callInProgress) { if (this.state.hasConference) {
const canEndConf = WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
controls.push( controls.push(
<HangupButton key="controls_hangup" roomId={this.props.room.roomId} />, <HangupButton
roomId={this.props.room.roomId}
isConference={true}
canEndConference={canEndConf}
isInConference={this.state.joinedConference}
/>,
);
} else if (callInProgress) {
controls.push(
<HangupButton key="controls_hangup" roomId={this.props.room.roomId} isConference={false} />,
); );
} else { } else {
controls.push( controls.push(

View file

@ -35,6 +35,7 @@ export default class ChangePassword extends React.Component {
rowClassName: PropTypes.string, rowClassName: PropTypes.string,
buttonClassName: PropTypes.string, buttonClassName: PropTypes.string,
buttonKind: PropTypes.string, buttonKind: PropTypes.string,
buttonLabel: PropTypes.string,
confirm: PropTypes.bool, confirm: PropTypes.bool,
// Whether to autoFocus the new password input // Whether to autoFocus the new password input
autoFocusNewPasswordInput: PropTypes.bool, autoFocusNewPasswordInput: PropTypes.bool,
@ -271,7 +272,7 @@ export default class ChangePassword extends React.Component {
/> />
</div> </div>
<AccessibleButton className={buttonClassName} kind={this.props.buttonKind} onClick={this.onClickChange}> <AccessibleButton className={buttonClassName} kind={this.props.buttonKind} onClick={this.onClickChange}>
{ _t('Change Password') } { this.props.buttonLabel || _t('Change Password') }
</AccessibleButton> </AccessibleButton>
</form> </form>
); );

View file

@ -18,6 +18,10 @@ import EditorModel from "./model";
import DocumentPosition, {Predicate} from "./position"; import DocumentPosition, {Predicate} from "./position";
import {Part} from "./parts"; import {Part} from "./parts";
const whitespacePredicate: Predicate = (index, offset, part) => {
return part.text[offset].trim() === "";
};
export default class Range { export default class Range {
private _start: DocumentPosition; private _start: DocumentPosition;
private _end: DocumentPosition; private _end: DocumentPosition;
@ -35,6 +39,11 @@ export default class Range {
}); });
} }
trim() {
this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
this._end = this._end.backwardsWhile(this.model, whitespacePredicate);
}
expandBackwardsWhile(predicate: Predicate) { expandBackwardsWhile(predicate: Predicate) {
this._start = this._start.backwardsWhile(this.model, predicate); this._start = this._start.backwardsWhile(this.model, predicate);
} }

View file

@ -50,12 +50,10 @@
"You cannot place a call with yourself.": "You cannot place a call with yourself.", "You cannot place a call with yourself.": "You cannot place a call with yourself.",
"Call in Progress": "Call in Progress", "Call in Progress": "Call in Progress",
"A call is currently being placed!": "A call is currently being placed!", "A call is currently being placed!": "A call is currently being placed!",
"End Call": "End Call",
"Remove the group call from the room?": "Remove the group call from the room?",
"Cancel": "Cancel",
"You don't have permission to remove the call from the room": "You don't have permission to remove the call from the room",
"Permission Required": "Permission Required", "Permission Required": "Permission Required",
"You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room", "You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room",
"End conference": "End conference",
"This will end the conference for everyone. Continue?": "This will end the conference for everyone. Continue?",
"Replying With Files": "Replying With Files", "Replying With Files": "Replying With Files",
"At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "At this time it is not possible to reply with a file. Would you like to upload this file without replying?", "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "At this time it is not possible to reply with a file. Would you like to upload this file without replying?",
"Continue": "Continue", "Continue": "Continue",
@ -143,6 +141,7 @@
"Cancel entering passphrase?": "Cancel entering passphrase?", "Cancel entering passphrase?": "Cancel entering passphrase?",
"Are you sure you want to cancel entering passphrase?": "Are you sure you want to cancel entering passphrase?", "Are you sure you want to cancel entering passphrase?": "Are you sure you want to cancel entering passphrase?",
"Go Back": "Go Back", "Go Back": "Go Back",
"Cancel": "Cancel",
"Setting up keys": "Setting up keys", "Setting up keys": "Setting up keys",
"Messages": "Messages", "Messages": "Messages",
"Actions": "Actions", "Actions": "Actions",
@ -277,9 +276,6 @@
"%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s", "%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s",
"%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s", "%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s",
"%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s", "%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s",
"Group call modified by %(senderName)s": "Group call modified by %(senderName)s",
"Group call started by %(senderName)s": "Group call started by %(senderName)s",
"Group call ended by %(senderName)s": "Group call ended by %(senderName)s",
"%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s removed the rule banning users matching %(glob)s", "%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s removed the rule banning users matching %(glob)s",
"%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s removed the rule banning rooms matching %(glob)s", "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s removed the rule banning rooms matching %(glob)s",
"%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s removed the rule banning servers matching %(glob)s", "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s removed the rule banning servers matching %(glob)s",
@ -1393,6 +1389,11 @@
"Invalid file%(extra)s": "Invalid file%(extra)s", "Invalid file%(extra)s": "Invalid file%(extra)s",
"Error decrypting image": "Error decrypting image", "Error decrypting image": "Error decrypting image",
"Show image": "Show image", "Show image": "Show image",
"Join the conference at the top of this room": "Join the conference at the top of this room",
"Join the conference from the room information card on the right": "Join the conference from the room information card on the right",
"Video conference ended by %(senderName)s": "Video conference ended by %(senderName)s",
"Video conference updated by %(senderName)s": "Video conference updated by %(senderName)s",
"Video conference started by %(senderName)s": "Video conference started by %(senderName)s",
"You have ignored this user, so their message is hidden. <a>Show anyways.</a>": "You have ignored this user, so their message is hidden. <a>Show anyways.</a>", "You have ignored this user, so their message is hidden. <a>Show anyways.</a>": "You have ignored this user, so their message is hidden. <a>Show anyways.</a>",
"You verified %(name)s": "You verified %(name)s", "You verified %(name)s": "You verified %(name)s",
"You cancelled verifying %(name)s": "You cancelled verifying %(name)s", "You cancelled verifying %(name)s": "You cancelled verifying %(name)s",

View file

@ -22,6 +22,7 @@ import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher"; import defaultDispatcher from "../dispatcher/dispatcher";
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import WidgetEchoStore from "../stores/WidgetEchoStore"; import WidgetEchoStore from "../stores/WidgetEchoStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import WidgetUtils from "../utils/WidgetUtils"; import WidgetUtils from "../utils/WidgetUtils";
import {SettingLevel} from "../settings/SettingLevel"; import {SettingLevel} from "../settings/SettingLevel";
import {WidgetType} from "../widgets/WidgetType"; import {WidgetType} from "../widgets/WidgetType";
@ -158,7 +159,8 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
let pinned = roomInfo && roomInfo.pinned[widgetId]; let pinned = roomInfo && roomInfo.pinned[widgetId];
// Jitsi widgets should be pinned by default // Jitsi widgets should be pinned by default
if (pinned === undefined && WidgetType.JITSI.matches(this.widgetMap.get(widgetId).type)) pinned = true; const widget = this.widgetMap.get(widgetId);
if (pinned === undefined && WidgetType.JITSI.matches(widget?.type)) pinned = true;
return pinned; return pinned;
} }
@ -206,6 +208,24 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
} }
return roomInfo.widgets; return roomInfo.widgets;
} }
public doesRoomHaveConference(room: Room): boolean {
const roomInfo = this.getRoom(room.roomId);
if (!roomInfo) return false;
const currentWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
const hasPendingWidgets = WidgetEchoStore.roomHasPendingWidgetsOfType(room.roomId, [], WidgetType.JITSI);
return currentWidgets.length > 0 || hasPendingWidgets;
}
public isJoinedToConferenceIn(room: Room): boolean {
const roomInfo = this.getRoom(room.roomId);
if (!roomInfo) return false;
// A persistent conference widget indicates that we're participating
const widgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
return widgets.some(w => ActiveWidgetStore.getWidgetPersistence(w.id));
}
} }
window.mxWidgetStore = WidgetStore.instance; window.mxWidgetStore = WidgetStore.instance;

View file

@ -39,6 +39,7 @@ export enum KnownWidgetActions {
SetAlwaysOnScreen = "set_always_on_screen", SetAlwaysOnScreen = "set_always_on_screen",
ClientReady = "im.vector.ready", ClientReady = "im.vector.ready",
Terminate = "im.vector.terminate", Terminate = "im.vector.terminate",
Hangup = "im.vector.hangup",
} }
export type WidgetAction = KnownWidgetActions | string; export type WidgetAction = KnownWidgetActions | string;
@ -119,13 +120,15 @@ export class WidgetApi extends EventEmitter {
// Automatically acknowledge so we can move on // Automatically acknowledge so we can move on
this.replyToRequest(<ToWidgetRequest>payload, {}); this.replyToRequest(<ToWidgetRequest>payload, {});
} else if (payload.action === KnownWidgetActions.Terminate) { } else if (payload.action === KnownWidgetActions.Terminate
|| payload.action === KnownWidgetActions.Hangup) {
// Finalization needs to be async, so postpone with a promise // Finalization needs to be async, so postpone with a promise
let finalizePromise = Promise.resolve(); let finalizePromise = Promise.resolve();
const wait = (promise) => { const wait = (promise) => {
finalizePromise = finalizePromise.then(() => promise); finalizePromise = finalizePromise.then(() => promise);
}; };
this.emit('terminate', wait); const emitName = payload.action === KnownWidgetActions.Terminate ? 'terminate' : 'hangup';
this.emit(emitName, wait);
Promise.resolve(finalizePromise).then(() => { Promise.resolve(finalizePromise).then(() => {
// Acknowledge that we're shut down now // Acknowledge that we're shut down now
this.replyToRequest(<ToWidgetRequest>payload, {}); this.replyToRequest(<ToWidgetRequest>payload, {});

View file

@ -88,4 +88,19 @@ describe('editor/range', function() {
expect(model.parts[1].text).toBe("man"); expect(model.parts[1].text).toBe("man");
expect(model.parts.length).toBe(2); expect(model.parts.length).toBe(2);
}); });
it('range trim spaces off both ends', () => {
const renderer = createRenderer();
const pc = createPartCreator();
const model = new EditorModel([
pc.plain("abc abc abc"),
], pc, renderer);
const range = model.startRange(
model.positionForOffset(3, false), // at end of first `abc`
model.positionForOffset(8, false), // at start of last `abc`
);
expect(range.parts[0].text).toBe(" abc ");
range.trim();
expect(range.parts[0].text).toBe("abc");
});
}); });