Merge remote-tracking branch 'origin/develop' into hs/bridge-info-v2
This commit is contained in:
commit
000a69765c
61 changed files with 3491 additions and 324 deletions
|
@ -80,6 +80,7 @@ import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityProt
|
|||
import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore";
|
||||
import {UIFeature} from "../../settings/UIFeature";
|
||||
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
|
||||
import DialPadModal from "../views/voip/DialPadModal";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -703,6 +704,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.state.resizeNotifier.notifyLeftHandleResized();
|
||||
});
|
||||
break;
|
||||
case Action.OpenDialPad:
|
||||
Modal.createTrackedDialog('Dial pad', '', DialPadModal, {}, "mx_Dialog_dialPadWrapper");
|
||||
break;
|
||||
case 'on_logged_in':
|
||||
if (
|
||||
!Lifecycle.isSoftLogout() &&
|
||||
|
|
|
@ -39,7 +39,7 @@ class NotificationPanel extends React.Component {
|
|||
|
||||
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
|
||||
<h2>{_t('You’re all caught up')}</h2>
|
||||
<p>{_t('You have no visible notifications in this room.')}</p>
|
||||
<p>{_t('You have no visible notifications.')}</p>
|
||||
</div>);
|
||||
|
||||
let content;
|
||||
|
|
|
@ -325,8 +325,7 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
// isn't a guest user since we'll usually have set a guest user session before
|
||||
// starting the registration process. This isn't perfect since it's possible
|
||||
// the user had a separate guest session they didn't actually mean to replace.
|
||||
const sessionOwner = Lifecycle.getStoredSessionOwner();
|
||||
const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
|
||||
const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner();
|
||||
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) {
|
||||
console.log(
|
||||
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
|
||||
|
@ -463,8 +462,7 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
let ssoSection;
|
||||
if (this.state.ssoFlow) {
|
||||
let continueWithSection;
|
||||
const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"]
|
||||
|| this.state.ssoFlow["identity_providers"] || [];
|
||||
const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"] || [];
|
||||
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text
|
||||
if (providers.length > 1) {
|
||||
// i18n: ssoButtons is a placeholder to help translators understand context
|
||||
|
|
|
@ -20,6 +20,8 @@ import { _t } from '../../../languageHandler';
|
|||
import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import CallHandler from '../../../CallHandler';
|
||||
import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog';
|
||||
import Modal from '../../../Modal';
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
call: MatrixCall;
|
||||
|
@ -46,14 +48,30 @@ export default class CallContextMenu extends React.Component<IProps> {
|
|||
this.props.onFinished();
|
||||
}
|
||||
|
||||
onTransferClick = () => {
|
||||
Modal.createTrackedDialog(
|
||||
'Transfer Call', '', InviteDialog, {kind: KIND_CALL_TRANSFER, call: this.props.call},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
|
||||
);
|
||||
this.props.onFinished();
|
||||
}
|
||||
|
||||
render() {
|
||||
const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold");
|
||||
const handler = this.props.call.isRemoteOnHold() ? this.onUnholdClick : this.onHoldClick;
|
||||
|
||||
let transferItem;
|
||||
if (this.props.call.opponentCanBeTransferred()) {
|
||||
transferItem = <MenuItem className="mx_CallContextMenu_item" onClick={this.onTransferClick}>
|
||||
{_t("Transfer")}
|
||||
</MenuItem>;
|
||||
}
|
||||
|
||||
return <ContextMenu {...this.props}>
|
||||
<MenuItem className="mx_CallContextMenu_item" onClick={handler}>
|
||||
{holdUnholdCaption}
|
||||
</MenuItem>
|
||||
{transferItem}
|
||||
</ContextMenu>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,13 +15,12 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import * as sdk from "../../../index";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import {RoomMember} from "matrix-js-sdk/src/matrix";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
import * as Email from "../../../email";
|
||||
|
@ -42,12 +41,14 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
|
||||
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
export const KIND_DM = "dm";
|
||||
export const KIND_INVITE = "invite";
|
||||
export const KIND_CALL_TRANSFER = "call_transfer";
|
||||
|
||||
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
|
||||
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
|
||||
|
@ -132,12 +133,12 @@ class ThreepidMember extends Member {
|
|||
}
|
||||
}
|
||||
|
||||
class DMUserTile extends React.PureComponent {
|
||||
static propTypes = {
|
||||
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
|
||||
onRemove: PropTypes.func, // takes 1 argument, the member being removed
|
||||
};
|
||||
interface IDMUserTileProps {
|
||||
member: RoomMember;
|
||||
onRemove: (RoomMember) => any;
|
||||
}
|
||||
|
||||
class DMUserTile extends React.PureComponent<IDMUserTileProps> {
|
||||
_onRemove = (e) => {
|
||||
// Stop the browser from highlighting text
|
||||
e.preventDefault();
|
||||
|
@ -173,7 +174,9 @@ class DMUserTile extends React.PureComponent {
|
|||
className='mx_InviteDialog_userTile_remove'
|
||||
onClick={this._onRemove}
|
||||
>
|
||||
<img src={require("../../../../res/img/icon-pill-remove.svg")} alt={_t('Remove')} width={8} height={8} />
|
||||
<img src={require("../../../../res/img/icon-pill-remove.svg")}
|
||||
alt={_t('Remove')} width={8} height={8}
|
||||
/>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
@ -190,15 +193,15 @@ class DMUserTile extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
class DMRoomTile extends React.PureComponent {
|
||||
static propTypes = {
|
||||
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
|
||||
lastActiveTs: PropTypes.number,
|
||||
onToggle: PropTypes.func.isRequired, // takes 1 argument, the member being toggled
|
||||
highlightWord: PropTypes.string,
|
||||
isSelected: PropTypes.bool,
|
||||
};
|
||||
interface IDMRoomTileProps {
|
||||
member: RoomMember;
|
||||
lastActiveTs: number;
|
||||
onToggle: (RoomMember) => any;
|
||||
highlightWord: string;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
|
||||
_onClick = (e) => {
|
||||
// Stop the browser from highlighting text
|
||||
e.preventDefault();
|
||||
|
@ -298,28 +301,48 @@ class DMRoomTile extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
export default class InviteDialog extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// Takes an array of user IDs/emails to invite.
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
interface IInviteDialogProps {
|
||||
// Takes an array of user IDs/emails to invite.
|
||||
onFinished: (toInvite?: string[]) => any;
|
||||
|
||||
// The kind of invite being performed. Assumed to be KIND_DM if
|
||||
// not provided.
|
||||
kind: PropTypes.string,
|
||||
// The kind of invite being performed. Assumed to be KIND_DM if
|
||||
// not provided.
|
||||
kind: string,
|
||||
|
||||
// The room ID this dialog is for. Only required for KIND_INVITE.
|
||||
roomId: PropTypes.string,
|
||||
// The room ID this dialog is for. Only required for KIND_INVITE.
|
||||
roomId: string,
|
||||
|
||||
// Initial value to populate the filter with
|
||||
initialText: PropTypes.string,
|
||||
};
|
||||
// The call to transfer. Only required for KIND_CALL_TRANSFER.
|
||||
call: MatrixCall,
|
||||
|
||||
// Initial value to populate the filter with
|
||||
initialText: string,
|
||||
}
|
||||
|
||||
interface IInviteDialogState {
|
||||
targets: RoomMember[]; // array of Member objects (see interface above)
|
||||
filterText: string;
|
||||
recents: { user: Member, userId: string }[];
|
||||
numRecentsShown: number;
|
||||
suggestions: { user: Member, userId: string }[];
|
||||
numSuggestionsShown: number;
|
||||
serverResultsMixin: { user: Member, userId: string }[];
|
||||
threepidResultsMixin: { user: Member, userId: string}[];
|
||||
canUseIdentityServer: boolean;
|
||||
tryingIdentityServer: boolean;
|
||||
|
||||
// These two flags are used for the 'Go' button to communicate what is going on.
|
||||
busy: boolean,
|
||||
errorText: string,
|
||||
}
|
||||
|
||||
export default class InviteDialog extends React.PureComponent<IInviteDialogProps, IInviteDialogState> {
|
||||
static defaultProps = {
|
||||
kind: KIND_DM,
|
||||
initialText: "",
|
||||
};
|
||||
|
||||
_debounceTimer: number = null;
|
||||
_debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser
|
||||
_editorRef: any = null;
|
||||
|
||||
constructor(props) {
|
||||
|
@ -327,6 +350,8 @@ export default class InviteDialog extends React.PureComponent {
|
|||
|
||||
if (props.kind === KIND_INVITE && !props.roomId) {
|
||||
throw new Error("When using KIND_INVITE a roomId is required for an InviteDialog");
|
||||
} else if (props.kind === KIND_CALL_TRANSFER && !props.call) {
|
||||
throw new Error("When using KIND_CALL_TRANSFER a call is required for an InviteDialog");
|
||||
}
|
||||
|
||||
const alreadyInvited = new Set([MatrixClientPeg.get().getUserId(), SdkConfig.get()['welcomeUserId']]);
|
||||
|
@ -348,8 +373,8 @@ export default class InviteDialog extends React.PureComponent {
|
|||
numRecentsShown: INITIAL_ROOMS_SHOWN,
|
||||
suggestions: this._buildSuggestions(alreadyInvited),
|
||||
numSuggestionsShown: INITIAL_ROOMS_SHOWN,
|
||||
serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions
|
||||
threepidResultsMixin: [], // { user: ThreepidMember, userId: string}[], like recents and suggestions
|
||||
serverResultsMixin: [],
|
||||
threepidResultsMixin: [],
|
||||
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
|
||||
tryingIdentityServer: false,
|
||||
|
||||
|
@ -367,7 +392,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
|
||||
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number}[] {
|
||||
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
|
||||
|
||||
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
|
||||
|
@ -430,7 +455,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
return recents;
|
||||
}
|
||||
|
||||
_buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember} {
|
||||
_buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] {
|
||||
const maxConsideredMembers = 200;
|
||||
const joinedRooms = MatrixClientPeg.get().getRooms()
|
||||
.filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers);
|
||||
|
@ -470,7 +495,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
}, {});
|
||||
|
||||
// Generates { userId: {member, numRooms, score} }
|
||||
const memberScores = Object.values(memberRooms).reduce((scores, entry) => {
|
||||
const memberScores = Object.values(memberRooms).reduce((scores, entry: {member: RoomMember, rooms: Room[]}) => {
|
||||
const numMembersTotal = entry.rooms.reduce((c, r) => c + r.getJoinedMemberCount(), 0);
|
||||
const maxRange = maxConsideredMembers * entry.rooms.length;
|
||||
scores[entry.member.userId] = {
|
||||
|
@ -603,7 +628,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
const createRoomOptions = {inlineErrors: true};
|
||||
const createRoomOptions = {inlineErrors: true} as any; // XXX: Type out `createRoomOptions`
|
||||
|
||||
if (privateShouldBeEncrypted()) {
|
||||
// Check whether all users have uploaded device keys before.
|
||||
|
@ -620,7 +645,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
|
||||
// Check if it's a traditional DM and create the room if required.
|
||||
// TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM
|
||||
let createRoomPromise = Promise.resolve();
|
||||
let createRoomPromise = Promise.resolve(null) as Promise<string | null | boolean>;
|
||||
const isSelf = targetIds.length === 1 && targetIds[0] === MatrixClientPeg.get().getUserId();
|
||||
if (targetIds.length === 1 && !isSelf) {
|
||||
createRoomOptions.dmUserId = targetIds[0];
|
||||
|
@ -684,6 +709,29 @@ export default class InviteDialog extends React.PureComponent {
|
|||
});
|
||||
};
|
||||
|
||||
_transferCall = async () => {
|
||||
this._convertFilter();
|
||||
const targets = this._convertFilter();
|
||||
const targetIds = targets.map(t => t.userId);
|
||||
if (targetIds.length > 1) {
|
||||
this.setState({
|
||||
errorText: _t("A call can only be transferred to a single user."),
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({busy: true});
|
||||
try {
|
||||
await this.props.call.transfer(targetIds[0]);
|
||||
this.setState({busy: false});
|
||||
this.props.onFinished();
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: _t("Failed to transfer call"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_onKeyDown = (e) => {
|
||||
if (this.state.busy) return;
|
||||
const value = e.target.value.trim();
|
||||
|
@ -990,7 +1038,8 @@ export default class InviteDialog extends React.PureComponent {
|
|||
const hasMixins = this.state.serverResultsMixin || this.state.threepidResultsMixin;
|
||||
if (this.state.filterText && hasMixins && kind === 'suggestions') {
|
||||
// We don't want to duplicate members though, so just exclude anyone we've already seen.
|
||||
const notAlreadyExists = (u: Member): boolean => {
|
||||
// The type of u is a pain to define but members of both mixins have the 'userId' property
|
||||
const notAlreadyExists = (u: any): boolean => {
|
||||
return !sourceMembers.some(m => m.userId === u.userId)
|
||||
&& !priorityAdditionalMembers.some(m => m.userId === u.userId)
|
||||
&& !otherAdditionalMembers.some(m => m.userId === u.userId);
|
||||
|
@ -1169,7 +1218,8 @@ export default class InviteDialog extends React.PureComponent {
|
|||
|
||||
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
|
||||
const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
|
||||
const inviteText = _t("This won't invite them to %(communityName)s. " +
|
||||
const inviteText = _t(
|
||||
"This won't invite them to %(communityName)s. " +
|
||||
"To invite someone to %(communityName)s, click <a>here</a>",
|
||||
{communityName}, {
|
||||
userId: () => {
|
||||
|
@ -1197,7 +1247,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
}
|
||||
buttonText = _t("Go");
|
||||
goButtonFn = this._startDm;
|
||||
} else { // KIND_INVITE
|
||||
} else if (this.props.kind === KIND_INVITE) {
|
||||
title = _t("Invite to this room");
|
||||
|
||||
if (identityServersEnabled) {
|
||||
|
@ -1209,7 +1259,9 @@ export default class InviteDialog extends React.PureComponent {
|
|||
userId: () =>
|
||||
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
|
||||
a: (sub) =>
|
||||
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">{sub}</a>,
|
||||
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">
|
||||
{sub}
|
||||
</a>,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
|
@ -1220,13 +1272,21 @@ export default class InviteDialog extends React.PureComponent {
|
|||
userId: () =>
|
||||
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
|
||||
a: (sub) =>
|
||||
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">{sub}</a>,
|
||||
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">
|
||||
{sub}
|
||||
</a>,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
buttonText = _t("Invite");
|
||||
goButtonFn = this._inviteUsers;
|
||||
} else if (this.props.kind === KIND_CALL_TRANSFER) {
|
||||
title = _t("Transfer");
|
||||
buttonText = _t("Transfer");
|
||||
goButtonFn = this._transferCall;
|
||||
} else {
|
||||
console.error("Unknown kind of InviteDialog: " + this.props.kind);
|
||||
}
|
||||
|
||||
const hasSelection = this.state.targets.length > 0
|
|
@ -45,8 +45,13 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
|
|||
};
|
||||
|
||||
let icon;
|
||||
if (idp && idp.icon && idp.icon.startsWith("https://")) {
|
||||
icon = <img src={idp.icon} height="24" width="24" alt={label} />;
|
||||
if (typeof idp?.icon === "string" && (idp.icon.startsWith("mxc://") || idp.icon.startsWith("https://"))) {
|
||||
icon = <img
|
||||
src={matrixClient.mxcUrlToHttp(idp.icon, 24, 24, "crop", true)}
|
||||
height="24"
|
||||
width="24"
|
||||
alt={label}
|
||||
/>;
|
||||
}
|
||||
|
||||
const classes = classNames("mx_SSOButton", {
|
||||
|
@ -79,7 +84,7 @@ interface IProps {
|
|||
}
|
||||
|
||||
const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => {
|
||||
const providers = flow.identity_providers || flow["org.matrix.msc2858.identity_providers"] || [];
|
||||
const providers = flow["org.matrix.msc2858.identity_providers"] || [];
|
||||
if (providers.length < 2) {
|
||||
return <div className="mx_SSOButtons">
|
||||
<SSOButton
|
||||
|
|
|
@ -47,6 +47,7 @@ import AutocompleteWrapperModel from "../../../editor/autocomplete";
|
|||
import DocumentPosition from "../../../editor/position";
|
||||
import {ICompletion} from "../../../autocomplete/Autocompleter";
|
||||
|
||||
// matches emoticons which follow the start of a line or whitespace
|
||||
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
|
||||
|
||||
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
|
||||
|
@ -524,7 +525,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
|
||||
const range = model.startRange(position);
|
||||
range.expandBackwardsWhile((index, offset, part) => {
|
||||
return part.text[offset] !== " " && (
|
||||
return part.text[offset] !== " " && part.text[offset] !== "+" && (
|
||||
part.type === "plain" ||
|
||||
part.type === "pill-candidate" ||
|
||||
part.type === "command"
|
||||
|
|
|
@ -46,6 +46,7 @@ import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
|
|||
import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
|
||||
import CallHandler from "../../../CallHandler";
|
||||
|
||||
interface IProps {
|
||||
onKeyDown: (ev: React.KeyboardEvent) => void;
|
||||
|
@ -89,10 +90,44 @@ interface ITagAesthetics {
|
|||
defaultHidden: boolean;
|
||||
}
|
||||
|
||||
const TAG_AESTHETICS: {
|
||||
interface ITagAestheticsMap {
|
||||
// @ts-ignore - TS wants this to be a string but we know better
|
||||
[tagId: TagID]: ITagAesthetics;
|
||||
} = {
|
||||
}
|
||||
|
||||
// If we have no dialer support, we just show the create chat dialog
|
||||
const dmOnAddRoom = (dispatcher?: Dispatcher<ActionPayload>) => {
|
||||
(dispatcher || defaultDispatcher).dispatch({action: 'view_create_chat'});
|
||||
};
|
||||
|
||||
// If we have dialer support, show a context menu so the user can pick between
|
||||
// the dialer and the create chat dialog
|
||||
const dmAddRoomContextMenu = (onFinished: () => void) => {
|
||||
return <IconizedContextMenuOptionList first>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Start a Conversation")}
|
||||
iconClassName="mx_RoomList_iconPlus"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFinished();
|
||||
defaultDispatcher.dispatch({action: "view_create_chat"});
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Open dial pad")}
|
||||
iconClassName="mx_RoomList_iconDialpad"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFinished();
|
||||
defaultDispatcher.fire(Action.OpenDialPad);
|
||||
}}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>;
|
||||
};
|
||||
|
||||
const TAG_AESTHETICS: ITagAestheticsMap = {
|
||||
[DefaultTagID.Invite]: {
|
||||
sectionLabel: _td("Invites"),
|
||||
isInvite: true,
|
||||
|
@ -108,9 +143,8 @@ const TAG_AESTHETICS: {
|
|||
isInvite: false,
|
||||
defaultHidden: false,
|
||||
addRoomLabel: _td("Start chat"),
|
||||
onAddRoom: (dispatcher?: Dispatcher<ActionPayload>) => {
|
||||
(dispatcher || defaultDispatcher).dispatch({action: 'view_create_chat'});
|
||||
},
|
||||
// Either onAddRoom or addRoomContextMenu are set depending on whether we
|
||||
// have dialer support.
|
||||
},
|
||||
[DefaultTagID.Untagged]: {
|
||||
sectionLabel: _td("Rooms"),
|
||||
|
@ -178,6 +212,7 @@ function customTagAesthetics(tagId: TagID): ITagAesthetics {
|
|||
export default class RoomList extends React.PureComponent<IProps, IState> {
|
||||
private dispatcherRef;
|
||||
private customTagStoreRef;
|
||||
private tagAesthetics: ITagAestheticsMap;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -187,6 +222,10 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
|||
isNameFiltering: !!RoomListStore.instance.getFirstNameFilterCondition(),
|
||||
};
|
||||
|
||||
// shallow-copy from the template as we need to make modifications to it
|
||||
this.tagAesthetics = objectShallowClone(TAG_AESTHETICS);
|
||||
this.updateDmAddRoomAction();
|
||||
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
}
|
||||
|
||||
|
@ -202,6 +241,17 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
|||
if (this.customTagStoreRef) this.customTagStoreRef.remove();
|
||||
}
|
||||
|
||||
private updateDmAddRoomAction() {
|
||||
const dmTagAesthetics = objectShallowClone(TAG_AESTHETICS[DefaultTagID.DM]);
|
||||
if (CallHandler.sharedInstance().getSupportsPstnProtocol()) {
|
||||
dmTagAesthetics.addRoomContextMenu = dmAddRoomContextMenu;
|
||||
} else {
|
||||
dmTagAesthetics.onAddRoom = dmOnAddRoom;
|
||||
}
|
||||
|
||||
this.tagAesthetics[DefaultTagID.DM] = dmTagAesthetics;
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === Action.ViewRoomDelta) {
|
||||
const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
|
||||
|
@ -214,6 +264,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
|||
show_room_tile: true, // to make sure the room gets scrolled into view
|
||||
});
|
||||
}
|
||||
} else if (payload.action === Action.PstnSupportUpdated) {
|
||||
this.updateDmAddRoomAction();
|
||||
this.updateLists();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -355,7 +408,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
|||
|
||||
const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
|
||||
? customTagAesthetics(orderedTagId)
|
||||
: TAG_AESTHETICS[orderedTagId];
|
||||
: this.tagAesthetics[orderedTagId];
|
||||
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
|
||||
|
||||
components.push(<RoomSublist
|
||||
|
|
|
@ -46,6 +46,8 @@ import {containsEmoji} from "../../../effects/utils";
|
|||
import {CHAT_EFFECTS} from '../../../effects';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import EMOJI_REGEX from 'emojibase-regex';
|
||||
|
||||
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
|
||||
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
|
||||
|
@ -91,6 +93,24 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) {
|
|||
return content;
|
||||
}
|
||||
|
||||
// exported for tests
|
||||
export function isQuickReaction(model) {
|
||||
const parts = model.parts;
|
||||
if (parts.length == 0) return false;
|
||||
const text = textSerialize(model);
|
||||
// shortcut takes the form "+:emoji:" or "+ :emoji:""
|
||||
// can be in 1 or 2 parts
|
||||
if (parts.length <= 2) {
|
||||
const hasShortcut = text.startsWith("+") || text.startsWith("+ ");
|
||||
const emojiMatch = text.match(EMOJI_REGEX);
|
||||
if (hasShortcut && emojiMatch && emojiMatch.length == 1) {
|
||||
return emojiMatch[0] === text.substring(1) ||
|
||||
emojiMatch[0] === text.substring(2);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default class SendMessageComposer extends React.Component {
|
||||
static propTypes = {
|
||||
room: PropTypes.object.isRequired,
|
||||
|
@ -223,6 +243,41 @@ export default class SendMessageComposer extends React.Component {
|
|||
return false;
|
||||
}
|
||||
|
||||
_sendQuickReaction() {
|
||||
const timeline = this.props.room.getLiveTimeline();
|
||||
const events = timeline.getEvents();
|
||||
const reaction = this.model.parts[1].text;
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
if (events[i].getType() === "m.room.message") {
|
||||
let shouldReact = true;
|
||||
const lastMessage = events[i];
|
||||
const userId = MatrixClientPeg.get().getUserId();
|
||||
const messageReactions = this.props.room.getUnfilteredTimelineSet()
|
||||
.getRelationsForEvent(lastMessage.getId(), "m.annotation", "m.reaction");
|
||||
|
||||
// if we have already sent this reaction, don't redact but don't re-send
|
||||
if (messageReactions) {
|
||||
const myReactionEvents = messageReactions.getAnnotationsBySender()[userId] || [];
|
||||
const myReactionKeys = [...myReactionEvents]
|
||||
.filter(event => !event.isRedacted())
|
||||
.map(event => event.getRelation().key);
|
||||
shouldReact = !myReactionKeys.includes(reaction);
|
||||
}
|
||||
if (shouldReact) {
|
||||
MatrixClientPeg.get().sendEvent(lastMessage.getRoomId(), "m.reaction", {
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
"event_id": lastMessage.getId(),
|
||||
"key": reaction,
|
||||
},
|
||||
});
|
||||
dis.dispatch({action: "message_sent"});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_getSlashCommand() {
|
||||
const commandText = this.model.parts.reduce((text, part) => {
|
||||
// use mxid to textify user pills in a command
|
||||
|
@ -310,6 +365,11 @@ export default class SendMessageComposer extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
if (isQuickReaction(this.model)) {
|
||||
shouldSend = false;
|
||||
this._sendQuickReaction();
|
||||
}
|
||||
|
||||
const replyToEvent = this.props.replyToEvent;
|
||||
if (shouldSend) {
|
||||
const startTime = CountlyAnalytics.getTimestamp();
|
||||
|
|
|
@ -19,7 +19,7 @@ import React, { createRef, CSSProperties, ReactNode } from 'react';
|
|||
import dis from '../../../dispatcher/dispatcher';
|
||||
import CallHandler from '../../../CallHandler';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import VideoFeed, { VideoFeedType } from "./VideoFeed";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
|
@ -423,7 +423,9 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold;
|
||||
let onHoldText = null;
|
||||
if (this.state.isRemoteOnHold) {
|
||||
onHoldText = _t("You held the call <a>Resume</a>", {}, {
|
||||
const holdString = CallHandler.sharedInstance().hasAnyUnheldCall() ?
|
||||
_td("You held the call <a>Switch</a>") : _td("You held the call <a>Resume</a>");
|
||||
onHoldText = _t(holdString, {}, {
|
||||
a: sub => <AccessibleButton kind="link" onClick={this.onCallResumeClick}>
|
||||
{sub}
|
||||
</AccessibleButton>,
|
||||
|
@ -478,20 +480,6 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
mx_CallView_voice: true,
|
||||
mx_CallView_voice_hold: isOnHold,
|
||||
});
|
||||
let secondaryCallAvatar: ReactNode;
|
||||
|
||||
if (this.props.secondaryCall) {
|
||||
const secAvatarSize = this.props.pipMode ? 40 : 100;
|
||||
secondaryCallAvatar = <div className="mx_CallView_voice_secondaryAvatarContainer"
|
||||
style={{width: secAvatarSize, height: secAvatarSize}}
|
||||
>
|
||||
<RoomAvatar
|
||||
room={secCallRoom}
|
||||
height={secAvatarSize}
|
||||
width={secAvatarSize}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
contentView = <div className={classes} onMouseMove={this.onMouseMove}>
|
||||
<div className="mx_CallView_voice_avatarsContainer">
|
||||
|
@ -502,7 +490,6 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
width={avatarSize}
|
||||
/>
|
||||
</div>
|
||||
{secondaryCallAvatar}
|
||||
</div>
|
||||
<div className="mx_CallView_voice_holdText">{onHoldText}</div>
|
||||
{callControls}
|
||||
|
@ -546,7 +533,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
<AccessibleButton element='span' onClick={this.onSecondaryRoomAvatarClick}>
|
||||
<RoomAvatar room={secCallRoom} height={16} width={16} />
|
||||
<span className="mx_CallView_secondaryCall_roomName">
|
||||
{_t("%(name)s paused", { name: secCallRoom.name })}
|
||||
{_t("%(name)s on hold", { name: secCallRoom.name })}
|
||||
</span>
|
||||
</AccessibleButton>
|
||||
</span>;
|
||||
|
|
82
src/components/views/voip/DialPad.tsx
Normal file
82
src/components/views/voip/DialPad.tsx
Normal file
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
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 * as React from "react";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
|
||||
const BUTTONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
|
||||
|
||||
enum DialPadButtonKind {
|
||||
Digit,
|
||||
Delete,
|
||||
Dial,
|
||||
}
|
||||
|
||||
interface IButtonProps {
|
||||
kind: DialPadButtonKind;
|
||||
digit?: string;
|
||||
onButtonPress: (string) => void;
|
||||
}
|
||||
|
||||
class DialPadButton extends React.PureComponent<IButtonProps> {
|
||||
onClick = () => {
|
||||
this.props.onButtonPress(this.props.digit);
|
||||
}
|
||||
|
||||
render() {
|
||||
switch (this.props.kind) {
|
||||
case DialPadButtonKind.Digit:
|
||||
return <AccessibleButton className="mx_DialPad_button" onClick={this.onClick}>
|
||||
{this.props.digit}
|
||||
</AccessibleButton>;
|
||||
case DialPadButtonKind.Delete:
|
||||
return <AccessibleButton className="mx_DialPad_button mx_DialPad_deleteButton"
|
||||
onClick={this.onClick}
|
||||
/>;
|
||||
case DialPadButtonKind.Dial:
|
||||
return <AccessibleButton className="mx_DialPad_button mx_DialPad_dialButton" onClick={this.onClick} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
onDigitPress: (string) => void;
|
||||
onDeletePress: (string) => void;
|
||||
onDialPress: (string) => void;
|
||||
}
|
||||
|
||||
export default class Dialpad extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
const buttonNodes = [];
|
||||
|
||||
for (const button of BUTTONS) {
|
||||
buttonNodes.push(<DialPadButton key={button} kind={DialPadButtonKind.Digit}
|
||||
digit={button} onButtonPress={this.props.onDigitPress}
|
||||
/>);
|
||||
}
|
||||
|
||||
buttonNodes.push(<DialPadButton key="del" kind={DialPadButtonKind.Delete}
|
||||
onButtonPress={this.props.onDeletePress}
|
||||
/>);
|
||||
buttonNodes.push(<DialPadButton key="dial" kind={DialPadButtonKind.Dial}
|
||||
onButtonPress={this.props.onDialPress}
|
||||
/>);
|
||||
|
||||
return <div className="mx_DialPad">
|
||||
{buttonNodes}
|
||||
</div>;
|
||||
}
|
||||
}
|
111
src/components/views/voip/DialPadModal.tsx
Normal file
111
src/components/views/voip/DialPadModal.tsx
Normal file
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
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 * as React from "react";
|
||||
import { ensureDMExists } from "../../../createRoom";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import Field from "../elements/Field";
|
||||
import DialPad from './DialPad';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../../views/dialogs/ErrorDialog";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (boolean) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default class DialpadModal extends React.PureComponent<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: '',
|
||||
}
|
||||
}
|
||||
|
||||
onCancelClick = () => {
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
|
||||
onChange = (ev) => {
|
||||
this.setState({value: ev.target.value});
|
||||
}
|
||||
|
||||
onFormSubmit = (ev) => {
|
||||
ev.preventDefault();
|
||||
this.onDialPress();
|
||||
}
|
||||
|
||||
onDigitPress = (digit) => {
|
||||
this.setState({value: this.state.value + digit});
|
||||
}
|
||||
|
||||
onDeletePress = () => {
|
||||
if (this.state.value.length === 0) return;
|
||||
this.setState({value: this.state.value.slice(0, -1)});
|
||||
}
|
||||
|
||||
onDialPress = async () => {
|
||||
const results = await MatrixClientPeg.get().getThirdpartyUser('im.vector.protocol.pstn', {
|
||||
'm.id.phone': this.state.value,
|
||||
});
|
||||
if (!results || results.length === 0 || !results[0].userid) {
|
||||
Modal.createTrackedDialog('', '', ErrorDialog, {
|
||||
title: _t("Unable to look up phone number"),
|
||||
description: _t("There was an error looking up the phone number"),
|
||||
});
|
||||
}
|
||||
const userId = results[0].userid;
|
||||
|
||||
const roomId = await ensureDMExists(MatrixClientPeg.get(), userId);
|
||||
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: roomId,
|
||||
});
|
||||
|
||||
this.props.onFinished(true);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="mx_DialPadModal">
|
||||
<div className="mx_DialPadModal_header">
|
||||
<div>
|
||||
<span className="mx_DialPadModal_title">{_t("Dial pad")}</span>
|
||||
<AccessibleButton className="mx_DialPadModal_cancel" onClick={this.onCancelClick} />
|
||||
</div>
|
||||
<form onSubmit={this.onFormSubmit}>
|
||||
<Field className="mx_DialPadModal_field" id="dialpad_number"
|
||||
value={this.state.value} autoFocus={true}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div className="mx_DialPadModal_horizSep" />
|
||||
<div className="mx_DialPadModal_dialPad">
|
||||
<DialPad onDigitPress={this.onDigitPress}
|
||||
onDeletePress={this.onDeletePress}
|
||||
onDialPress={this.onDialPress}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -24,7 +24,7 @@ import { ActionPayload } from '../../../dispatcher/payloads';
|
|||
import CallHandler from '../../../CallHandler';
|
||||
import RoomAvatar from '../avatars/RoomAvatar';
|
||||
import FormButton from '../elements/FormButton';
|
||||
import { CallState } from 'matrix-js-sdk/lib/webrtc/call';
|
||||
import { CallState } from 'matrix-js-sdk/src/webrtc/call';
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue