Merge branch 'develop' into edit-slashcommands

This commit is contained in:
Travis Ralston 2021-05-10 20:09:33 -06:00
commit 12657f0fbc
336 changed files with 11521 additions and 4060 deletions

View file

@ -169,7 +169,7 @@ export class PasswordAuthEntry extends React.Component {
{ submitButtonOrSpinner }
</div>
</form>
{ errorSection }
{ errorSection }
</div>
);
}
@ -375,7 +375,7 @@ export class TermsAuthEntry extends React.Component {
if (this.props.showContinue !== false) {
// XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
}
return (

View file

@ -68,8 +68,8 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
let imageUrl = null;
if (props.member.getMxcAvatarUrl()) {
imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.width,
props.height,
props.resizeMethod,
);
}

View file

@ -93,8 +93,8 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
let oobAvatar = null;
if (props.oobData.avatarUrl) {
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.width,
props.height,
props.resizeMethod,
);
}
@ -109,12 +109,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
private static getRoomAvatarUrl(props: IProps): string {
if (!props.room) return null;
return Avatar.avatarUrlForRoom(
props.room,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
);
return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod);
}
private onRoomAvatarClick = () => {

View file

@ -1,8 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016, 2018, 2019, 2021 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.
@ -34,7 +32,7 @@ import {MenuItem} from "../../structures/ContextMenu";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {replaceableComponent} from "../../../utils/replaceableComponent";
function canCancel(eventStatus) {
export function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
}
@ -80,8 +78,10 @@ export default class MessageContextMenu extends React.Component {
// We explicitly decline to show the redact option on ACL events as it has a potential
// to obliterate the room - https://github.com/matrix-org/synapse/issues/4042
// Similarly for encryption events, since redacting them "breaks everything"
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl;
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl
&& this.props.mxEvent.getType() !== EventType.RoomEncryption;
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
@ -98,21 +98,6 @@ export default class MessageContextMenu extends React.Component {
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
}
onResendClick = () => {
Resend.resend(this.props.mxEvent);
this.closeMenu();
};
onResendEditClick = () => {
Resend.resend(this.props.mxEvent.replacingEvent());
this.closeMenu();
};
onResendRedactionClick = () => {
Resend.resend(this.props.mxEvent.localRedactionEvent());
this.closeMenu();
};
onResendReactionsClick = () => {
for (const reaction of this._getUnsentReactions()) {
Resend.resend(reaction);
@ -170,29 +155,6 @@ export default class MessageContextMenu extends React.Component {
this.closeMenu();
};
onCancelSendClick = () => {
const mxEvent = this.props.mxEvent;
const editEvent = mxEvent.replacingEvent();
const redactEvent = mxEvent.localRedactionEvent();
const pendingReactions = this._getPendingReactions();
if (editEvent && canCancel(editEvent.status)) {
Resend.removeFromQueue(editEvent);
}
if (redactEvent && canCancel(redactEvent.status)) {
Resend.removeFromQueue(redactEvent);
}
if (pendingReactions.length) {
for (const reaction of pendingReactions) {
Resend.removeFromQueue(reaction);
}
}
if (canCancel(mxEvent.status)) {
Resend.removeFromQueue(this.props.mxEvent);
}
this.closeMenu();
};
onForwardClick = () => {
if (this.props.onCloseDialog) this.props.onCloseDialog();
dis.dispatch({
@ -285,20 +247,9 @@ export default class MessageContextMenu extends React.Component {
const me = cli.getUserId();
const mxEvent = this.props.mxEvent;
const eventStatus = mxEvent.status;
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const unsentReactionsCount = this._getUnsentReactions().length;
const pendingReactionsCount = this._getPendingReactions().length;
const allowCancel = canCancel(mxEvent.status) ||
canCancel(editStatus) ||
canCancel(redactStatus) ||
pendingReactionsCount !== 0;
let resendButton;
let resendEditButton;
let resendReactionsButton;
let resendRedactionButton;
let redactButton;
let cancelButton;
let forwardButton;
let pinButton;
let unhidePreviewButton;
@ -309,22 +260,6 @@ export default class MessageContextMenu extends React.Component {
// status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (!mxEvent.isRedacted()) {
if (eventStatus === EventStatus.NOT_SENT) {
resendButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') }
</MenuItem>
);
}
if (editStatus === EventStatus.NOT_SENT) {
resendEditButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendEditClick}>
{ _t('Resend edit') }
</MenuItem>
);
}
if (unsentReactionsCount !== 0) {
resendReactionsButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}>
@ -334,14 +269,6 @@ export default class MessageContextMenu extends React.Component {
}
}
if (redactStatus === EventStatus.NOT_SENT) {
resendRedactionButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendRedactionClick}>
{ _t('Resend removal') }
</MenuItem>
);
}
if (isSent && this.state.canRedact) {
redactButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
@ -350,14 +277,6 @@ export default class MessageContextMenu extends React.Component {
);
}
if (allowCancel) {
cancelButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
{ _t('Cancel Sending') }
</MenuItem>
);
}
if (isContentActionable(mxEvent)) {
forwardButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
@ -433,7 +352,7 @@ export default class MessageContextMenu extends React.Component {
>
{ _t('Source URL') }
</MenuItem>
);
);
}
if (this.props.collapseReplyThread) {
@ -455,12 +374,8 @@ export default class MessageContextMenu extends React.Component {
return (
<div className="mx_MessageContextMenu">
{ resendButton }
{ resendEditButton }
{ resendReactionsButton }
{ resendRedactionButton }
{ redactButton }
{ cancelButton }
{ forwardButton }
{ pinButton }
{ viewSourceButton }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from "react";
import React, {useContext, useMemo, useState} from "react";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
@ -29,10 +29,13 @@ import RoomAvatar from "../avatars/RoomAvatar";
import {getDisplayAliasForRoom} from "../../../Rooms";
import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {allSettled} from "../../../utils/promise";
import {sleep} from "../../../utils/promise";
import DMRoomMap from "../../../utils/DMRoomMap";
import {calculateRoomVia} from "../../../utils/permalinks/Permalinks";
import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import ProgressBar from "../elements/ProgressBar";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -41,43 +44,128 @@ interface IProps extends IDialogProps {
}
const Entry = ({ room, checked, onChange }) => {
return <div className="mx_AddExistingToSpaceDialog_entry">
return <label className="mx_AddExistingToSpace_entry">
<RoomAvatar room={room} height={32} width={32} />
<span className="mx_AddExistingToSpaceDialog_entry_name">{ room.name }</span>
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} />
<span className="mx_AddExistingToSpace_entry_name">{ room.name }</span>
<StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : null}
checked={checked}
disabled={!onChange}
/>
</label>;
};
interface IAddExistingToSpaceProps {
space: Room;
selected: Set<Room>;
onChange(checked: boolean, room: Room): void;
}
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space, selected, onChange }) => {
const cli = useContext(MatrixClientContext);
const visibleRooms = useMemo(() => sortRooms(cli.getVisibleRooms()), [cli]);
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const existingSubspacesSet = new Set(existingSubspaces);
const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId));
const joinRule = space.getJoinRule();
const [spaces, rooms, dms] = visibleRooms.reduce((arr, room) => {
if (room.getMyMembership() !== "join") return arr;
if (!room.name.toLowerCase().includes(lcQuery)) return arr;
if (room.isSpaceRoom()) {
if (room !== space && !existingSubspacesSet.has(room)) {
arr[0].push(room);
}
} else if (!existingRoomsSet.has(room)) {
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
arr[1].push(room);
} else if (joinRule !== "public") {
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
arr[2].push(room);
}
}
return arr;
}, [[], [], []]);
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpace_content" id="mx_AddExistingToSpace">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selected.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
) : null }
{ dms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const [selectedSpace, setSelectedSpace] = useState(space);
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const existingSubspacesSet = new Set(existingSubspaces);
const spaces = SpaceStore.instance.getSpaces().filter(s => {
return !existingSubspacesSet.has(s) // not already in space
&& space !== s // not the top-level space
&& selectedSpace !== s // not the selected space
&& s.name.toLowerCase().includes(lcQuery); // contains query
});
const existingRooms = SpaceStore.instance.getChildRooms(space.roomId);
const existingRoomsSet = new Set(existingRooms);
const rooms = cli.getVisibleRooms().filter(room => {
return !existingRoomsSet.has(room) // not already in space
&& !room.isSpaceRoom() // not a space itself
&& room.name.toLowerCase().includes(lcQuery) // contains query
&& !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [progress, setProgress] = useState<number>(null);
const [error, setError] = useState<Error>(null);
let spaceOptionSection;
if (existingSubspacesSet.size > 0) {
if (existingSubspaces.length > 0) {
const options = [space, ...existingSubspaces].map((space) => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
@ -114,95 +202,106 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
</div>
</React.Fragment>;
return <BaseDialog
title={title}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpaceDialog"
onFinished={onFinished}
fixedWidth={false}
>
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
const addRooms = async () => {
setError(null);
setProgress(0);
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : undefined }
let error;
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section mx_AddExistingToSpaceDialog_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(space);
} else {
selectedToAdd.delete(space);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : null }
for (const room of selectedToAdd) {
const via = calculateRoomVia(room);
try {
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
if (e.errcode === "M_LIMIT_EXCEEDED") {
await sleep(e.data.retry_after_ms);
return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
}
{ spaces.length + rooms.length < 1 ? <span className="mx_AddExistingToSpaceDialog_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
throw e;
});
setProgress(i => i + 1);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(error = e);
break;
}
}
<div className="mx_AddExistingToSpaceDialog_footer">
if (!error) {
onFinished(true);
}
};
const busy = progress !== null;
let footer;
if (error) {
footer = <>
<img
src={require("../../../../res/img/element-icons/warning-badge.svg")}
height="24"
width="24"
alt=""
/>
<span className="mx_AddExistingToSpaceDialog_error">
<div className="mx_AddExistingToSpaceDialog_errorHeading">{ _t("Not all selected were added") }</div>
<div className="mx_AddExistingToSpaceDialog_errorCaption">{ _t("Try again") }</div>
</span>
<AccessibleButton className="mx_AddExistingToSpaceDialog_retryButton" onClick={addRooms}>
{ _t("Retry") }
</AccessibleButton>
</>;
} else if (busy) {
footer = <span>
<ProgressBar value={progress} max={selectedToAdd.size} />
<div className="mx_AddExistingToSpaceDialog_progressText">
{ _t("Adding rooms... (%(progress)s out of %(count)s)", {
count: selectedToAdd.size,
progress,
}) }
</div>
</span>;
} else {
footer = <>
<span>
<div>{ _t("Don't want to add an existing room?") }</div>
<div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") }
</AccessibleButton>
</span>
<AccessibleButton
kind="primary"
disabled={busy || selectedToAdd.size < 1}
onClick={async () => {
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
onFinished(true);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
}
setBusy(false);
}}
>
{ busy ? _t("Adding...") : _t("Add") }
<AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
{ _t("Add") }
</AccessibleButton>
</>;
}
return <BaseDialog
title={title}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={cli}>
<AddExistingToSpace
space={space}
selected={selectedToAdd}
onChange={!busy && !error ? (checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
} : null}
/>
</MatrixClientContext.Provider>
<div className="mx_AddExistingToSpaceDialog_footer">
{ footer }
</div>
</BaseDialog>;
};

View file

@ -184,7 +184,7 @@ export default class BugReportDialog extends React.Component {
return (
<BaseDialog className="mx_BugReportDialog" onFinished={this._onCancel}
title={_t('Submit debug logs')}
title={_t('Submit debug logs')}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>

View file

@ -95,7 +95,7 @@ export default class ChangelogDialog extends React.Component {
description={content}
button={_t("Update")}
onFinished={this.props.onFinished}
/>
/>
);
}
}

View file

@ -39,9 +39,12 @@ export default class ConfirmWipeDeviceDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_ConfirmWipeDeviceDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Clear all data in this session?")}>
<BaseDialog
className='mx_ConfirmWipeDeviceDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Clear all data in this session?")}
>
<div className='mx_ConfirmWipeDeviceDialog_content'>
<p>
{_t(

View file

@ -70,8 +70,16 @@ class GenericEditor extends React.PureComponent {
}
textInput(id, label) {
return <Field id={id} label={label} size="42" autoFocus={true} type="text" autoComplete="on"
value={this.state[id]} onChange={this._onChange} />;
return <Field
id={id}
label={label}
size="42"
autoFocus={true}
type="text"
autoComplete="on"
value={this.state[id]}
onChange={this._onChange}
/>;
}
}
@ -155,7 +163,7 @@ export class SendCustomEvent extends GenericEditor {
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
@ -239,7 +247,7 @@ class SendAccountData extends GenericEditor {
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
@ -315,15 +323,15 @@ class FilteredList extends React.PureComponent {
const TruncatedList = sdk.getComponent("elements.TruncatedList");
return <div>
<Field label={_t('Filter results')} autoFocus={true} size={64}
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} />
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} />
<TruncatedList getChildren={this.getChildren}
getChildCount={this.getChildCount}
truncateAt={this.state.truncateAt}
createOverflowElement={this.createOverflowElement} />
getChildCount={this.getChildCount}
truncateAt={this.state.truncateAt}
createOverflowElement={this.createOverflowElement} />
</div>;
}
}
@ -647,7 +655,7 @@ function VerificationRequest({txnId, request}) {
/* Note that request.timeout is a getter, so its value changes */
const id = setInterval(() => {
setRequestTimeout(request.timeout);
setRequestTimeout(request.timeout);
}, 500);
return () => { clearInterval(id); };
@ -941,35 +949,35 @@ class SettingsExplorer extends React.Component {
/>
<table>
<thead>
<tr>
<th>{_t("Setting ID")}</th>
<th>{_t("Value")}</th>
<th>{_t("Value in this room")}</th>
</tr>
<tr>
<th>{_t("Setting ID")}</th>
<th>{_t("Value")}</th>
<th>{_t("Value in this room")}</th>
</tr>
</thead>
<tbody>
{allSettings.map(i => (
<tr key={i}>
<td>
<a href="" onClick={(e) => this.onViewClick(e, i)}>
<code>{i}</code>
</a>
<a href="" onClick={(e) => this.onEditClick(e, i)}
className='mx_DevTools_SettingsExplorer_edit'
>
{allSettings.map(i => (
<tr key={i}>
<td>
<a href="" onClick={(e) => this.onViewClick(e, i)}>
<code>{i}</code>
</a>
<a href="" onClick={(e) => this.onEditClick(e, i)}
className='mx_DevTools_SettingsExplorer_edit'
>
</a>
</td>
<td>
<code>{this.renderSettingValue(SettingsStore.getValue(i))}</code>
</td>
<td>
<code>
{this.renderSettingValue(SettingsStore.getValue(i, room.roomId))}
</code>
</td>
</tr>
))}
</a>
</td>
<td>
<code>{this.renderSettingValue(SettingsStore.getValue(i))}</code>
</td>
<td>
<code>
{this.renderSettingValue(SettingsStore.getValue(i, room.roomId))}
</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
@ -998,11 +1006,11 @@ class SettingsExplorer extends React.Component {
<div>
<table>
<thead>
<tr>
<th>{_t("Level")}</th>
<th>{_t("Settable at global")}</th>
<th>{_t("Settable at room")}</th>
</tr>
<tr>
<th>{_t("Level")}</th>
<th>{_t("Settable at global")}</th>
<th>{_t("Settable at room")}</th>
</tr>
</thead>
<tbody>
{LEVEL_ORDER.map(lvl => (

View file

@ -130,7 +130,7 @@ export default class IncomingSasDialog extends React.Component {
const oppProfile = this.state.opponentProfile;
if (oppProfile) {
const url = oppProfile.avatar_url
? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(Math.floor(48 * window.devicePixelRatio))
? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48)
: null;
profile = <div className="mx_IncomingSasDialog_opponentProfile">
<BaseAvatar name={oppProfile.displayname}

View file

@ -42,9 +42,12 @@ export default class IntegrationsDisabledDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_IntegrationsDisabledDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Integrations are disabled")}>
<BaseDialog
className='mx_IntegrationsDisabledDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Integrations are disabled")}
>
<div className='mx_IntegrationsDisabledDialog_content'>
<p>{_t("Enable 'Manage Integrations' in Settings to do this.")}</p>
</div>

View file

@ -37,9 +37,12 @@ export default class IntegrationsImpossibleDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_IntegrationsImpossibleDialog' hasCancel={false}
onFinished={this.props.onFinished}
title={_t("Integrations not allowed")}>
<BaseDialog
className='mx_IntegrationsImpossibleDialog'
hasCancel={false}
onFinished={this.props.onFinished}
title={_t("Integrations not allowed")}
>
<div className='mx_IntegrationsImpossibleDialog_content'>
<p>
{_t(

View file

@ -31,6 +31,7 @@ import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize";
import createRoom, {
canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted,
IInvite3PID,
} from "../../../createRoom";
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
import {Key} from "../../../Keyboard";
@ -618,13 +619,14 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
_startDm = async () => {
this.setState({busy: true});
const client = MatrixClientPeg.get();
const targets = this._convertFilter();
const targetIds = targets.map(t => t.userId);
// Check if there is already a DM with these people and reuse it if possible.
let existingRoom: Room;
if (targetIds.length === 1) {
existingRoom = findDMForUser(MatrixClientPeg.get(), targetIds[0]);
existingRoom = findDMForUser(client, targetIds[0]);
} else {
existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
}
@ -646,7 +648,6 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
// If so, enable encryption in the new room.
const has3PidMembers = targets.some(t => t instanceof ThreepidMember);
if (!has3PidMembers) {
const client = MatrixClientPeg.get();
const allHaveDeviceKeys = await canEncryptToAllUsers(client, targetIds);
if (allHaveDeviceKeys) {
createRoomOptions.encryption = true;
@ -656,35 +657,41 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
// 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(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];
createRoomPromise = createRoom(createRoomOptions);
} else if (isSelf) {
createRoomPromise = createRoom(createRoomOptions);
} else {
// Create a boring room and try to invite the targets manually.
createRoomPromise = createRoom(createRoomOptions).then(roomId => {
return inviteMultipleToRoom(roomId, targetIds);
}).then(result => {
if (this._shouldAbortAfterInviteError(result)) {
return true; // abort
}
});
}
try {
const isSelf = targetIds.length === 1 && targetIds[0] === client.getUserId();
if (targetIds.length === 1 && !isSelf) {
createRoomOptions.dmUserId = targetIds[0];
}
// the createRoom call will show the room for us, so we don't need to worry about that.
createRoomPromise.then(abort => {
if (abort === true) return; // only abort on true booleans, not roomIds or something
if (targetIds.length > 1) {
createRoomOptions.createOpts = targetIds.reduce(
(roomOptions, address) => {
const type = getAddressType(address);
if (type === 'email') {
const invite: IInvite3PID = {
id_server: client.getIdentityServerUrl(true),
medium: 'email',
address,
};
roomOptions.invite_3pid.push(invite);
} else if (type === 'mx-user-id') {
roomOptions.invite.push(address);
}
return roomOptions;
},
{ invite: [], invite_3pid: [] },
)
}
await createRoom(createRoomOptions);
this.props.onFinished();
}).catch(err => {
} catch (err) {
console.error(err);
this.setState({
busy: false,
errorText: _t("We couldn't create your DM."),
});
});
}
};
_inviteUsers = async () => {
@ -1305,7 +1312,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
goButtonFn = this._startDm;
} else if (this.props.kind === KIND_INVITE) {
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId);
const isSpace = room?.isSpaceRoom();
const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom();
title = isSpace
? _t("Invite to %(spaceName)s", {
spaceName: room.name || _t("Unnamed Space"),

View file

@ -24,7 +24,7 @@ export default function KeySignatureUploadFailedDialog({
source,
continuation,
onFinished,
}) {
}) {
const RETRIES = 2;
const BaseDialog = sdk.getComponent('dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
@ -84,10 +84,10 @@ export default function KeySignatureUploadFailedDialog({
} else {
body = (<div>
{success ?
<span>{_t("Upload completed")}</span> :
cancelled ?
<span>{_t("Cancelled signature upload")}</span> :
<span>{_t("Unable to upload")}</span>}
<span>{_t("Upload completed")}</span> :
cancelled ?
<span>{_t("Cancelled signature upload")}</span> :
<span>{_t("Unable to upload")}</span>}
<DialogButtons
primaryButton={_t("OK")}
hasCancel={false}

View file

@ -164,8 +164,12 @@ export default class MessageEditHistoryDialog extends React.PureComponent {
}
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className='mx_MessageEditHistoryDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Message edits")}>
<BaseDialog
className='mx_MessageEditHistoryDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Message edits")}
>
{content}
</BaseDialog>
);

View file

@ -116,8 +116,12 @@ export default class RoomSettingsDialog extends React.Component {
const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name;
return (
<BaseDialog className='mx_RoomSettingsDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Room Settings - %(roomName)s", {roomName})}>
<BaseDialog
className='mx_RoomSettingsDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Room Settings - %(roomName)s", {roomName})}
>
<div className='mx_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} />
</div>

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2021 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.
@ -110,7 +110,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
console.error(e);
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (stateForError.isFatalError) {
if (stateForError.serverErrorIsFatal) {
let error = _t("Unable to validate homeserver");
if (e.translatedMessage) {
error = e.translatedMessage;
@ -168,7 +168,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
text = _t("Matrix.org is the biggest public homeserver in the world, so its a good place for many.");
}
let defaultServerName = this.defaultServer.hsName;
let defaultServerName: React.ReactNode = this.defaultServer.hsName;
if (this.defaultServer.hsNameIsDifferent) {
defaultServerName = (
<TextWithTooltip class="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}>

View file

@ -36,7 +36,7 @@ export default class SeshatResetDialog extends React.PureComponent<IDialogProps>
{_t("You most likely do not want to reset your event index store")}
<br />
{_t("If you do, please note that none of your messages will be deleted, " +
"but the search experience might be degraded for a few moments" +
"but the search experience might be degraded for a few moments " +
"whilst the index is recreated",
)}
</p>

View file

@ -98,7 +98,7 @@ export default class SessionRestoreErrorDialog extends React.Component {
"may be incompatible with this version. Close this window and return " +
"to the more recent version.",
{ brand },
) }</p>
) }</p>
<p>{ _t(
"Clearing your browser's storage may fix the problem, but will sign you " +

View file

@ -45,10 +45,12 @@ export default class StorageEvictedDialog extends React.Component {
let logRequest;
if (SdkConfig.get().bug_report_endpoint_url) {
logRequest = _t(
"To help us prevent this in future, please <a>send us logs</a>.", {},
{
a: text => <a href="#" onClick={this._sendBugReport}>{text}</a>,
});
"To help us prevent this in future, please <a>send us logs</a>.",
{},
{
a: text => <a href="#" onClick={this._sendBugReport}>{text}</a>,
},
);
}
return (

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
@ -16,20 +16,23 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import filesize from "filesize";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getBlobSafeMimeType } from '../../../utils/blobs';
interface IProps {
file: File;
currentIndex: number;
totalFiles?: number;
onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void;
}
@replaceableComponent("views.dialogs.UploadConfirmDialog")
export default class UploadConfirmDialog extends React.Component {
static propTypes = {
file: PropTypes.object.isRequired,
currentIndex: PropTypes.number,
totalFiles: PropTypes.number,
onFinished: PropTypes.func.isRequired,
}
export default class UploadConfirmDialog extends React.Component<IProps> {
private objectUrl: string;
private mimeType: string;
static defaultProps = {
totalFiles: 1,
@ -38,22 +41,28 @@ export default class UploadConfirmDialog extends React.Component {
constructor(props) {
super(props);
this._objectUrl = URL.createObjectURL(props.file);
// Create a fresh `Blob` for previewing (even though `File` already is
// one) so we can adjust the MIME type if needed.
this.mimeType = getBlobSafeMimeType(props.file.type);
const blob = new Blob([props.file], { type:
this.mimeType,
});
this.objectUrl = URL.createObjectURL(blob);
}
componentWillUnmount() {
if (this._objectUrl) URL.revokeObjectURL(this._objectUrl);
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
}
_onCancelClick = () => {
private onCancelClick = () => {
this.props.onFinished(false);
}
_onUploadClick = () => {
private onUploadClick = () => {
this.props.onFinished(true);
}
_onUploadAllClick = () => {
private onUploadAllClick = () => {
this.props.onFinished(true, true);
}
@ -75,10 +84,10 @@ export default class UploadConfirmDialog extends React.Component {
}
let preview;
if (this.props.file.type.startsWith('image/')) {
if (this.mimeType.startsWith('image/')) {
preview = <div className="mx_UploadConfirmDialog_previewOuter">
<div className="mx_UploadConfirmDialog_previewInner">
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this._objectUrl} /></div>
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this.objectUrl} /></div>
<div>{this.props.file.name} ({filesize(this.props.file.size)})</div>
</div>
</div>;
@ -95,7 +104,7 @@ export default class UploadConfirmDialog extends React.Component {
let uploadAllButton;
if (this.props.currentIndex + 1 < this.props.totalFiles) {
uploadAllButton = <button onClick={this._onUploadAllClick}>
uploadAllButton = <button onClick={this.onUploadAllClick}>
{_t("Upload all")}
</button>;
}
@ -103,7 +112,7 @@ export default class UploadConfirmDialog extends React.Component {
return (
<BaseDialog className='mx_UploadConfirmDialog'
fixedWidth={false}
onFinished={this._onCancelClick}
onFinished={this.onCancelClick}
title={title}
contentId='mx_Dialog_content'
>
@ -113,7 +122,7 @@ export default class UploadConfirmDialog extends React.Component {
<DialogButtons primaryButton={_t('Upload')}
hasCancel={false}
onPrimaryButtonClick={this._onUploadClick}
onPrimaryButtonClick={this.onUploadClick}
focus={true}
>
{uploadAllButton}

View file

@ -155,8 +155,12 @@ export default class UserSettingsDialog extends React.Component {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className='mx_UserSettingsDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Settings")}>
<BaseDialog
className='mx_UserSettingsDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Settings")}
>
<div className='mx_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} />
</div>

View file

@ -52,11 +52,13 @@ export default class VerificationRequestDialog extends React.Component {
const title = request && request.isSelfVerification ?
_t("Verify other login") : _t("Verification Request");
return <BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished}
contentId="mx_Dialog_content"
title={title}
hasCancel={true}
>
return <BaseDialog
className="mx_InfoDialog"
onFinished={this.props.onFinished}
contentId="mx_Dialog_content"
title={title}
hasCancel={true}
>
<EncryptionPanel
layout="dialog"
verificationRequest={this.props.verificationRequest}

View file

@ -70,9 +70,12 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_WidgetOpenIDPermissionsDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Allow this widget to verify your identity")}>
<BaseDialog
className='mx_WidgetOpenIDPermissionsDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Allow this widget to verify your identity")}
>
<div className='mx_WidgetOpenIDPermissionsDialog_content'>
<p>
{_t("The widget will verify your user ID, but won't be able to perform actions for you:")}

View file

@ -40,10 +40,11 @@ export default class ConfirmDestroyCrossSigningDialog extends React.Component {
return (
<BaseDialog
className='mx_ConfirmDestroyCrossSigningDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Destroy cross-signing keys?")}>
className='mx_ConfirmDestroyCrossSigningDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Destroy cross-signing keys?")}
>
<div className='mx_ConfirmDestroyCrossSigningDialog_content'>
<p>
{_t(

View file

@ -373,21 +373,24 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
{_t(
"If you've forgotten your Security Phrase you can "+
"<button1>use your Security Key</button1> or " +
"<button2>set up new recovery options</button2>"
, {}, {
button1: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onUseRecoveryKeyClick}
>
{s}
</AccessibleButton>,
button2: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
})}
"<button2>set up new recovery options</button2>",
{},
{
button1: s => <AccessibleButton
className="mx_linkButton"
element="span"
onClick={this._onUseRecoveryKeyClick}
>
{s}
</AccessibleButton>,
button2: s => <AccessibleButton
className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
})}
</div>;
} else {
title = _t("Enter Security Key");
@ -435,15 +438,17 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
</div>
{_t(
"If you've forgotten your Security Key you can "+
"<button>set up new recovery options</button>"
, {}, {
button: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
})}
"<button>set up new recovery options</button>",
{},
{
button: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
},
)}
</div>;
}
@ -452,9 +457,9 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
onFinished={this.props.onFinished}
title={title}
>
<div className='mx_RestoreKeyBackupDialog_content'>
{content}
</div>
<div className='mx_RestoreKeyBackupDialog_content'>
{content}
</div>
</BaseDialog>
);
}

View file

@ -70,8 +70,8 @@ export default class ActionButton extends React.Component {
}
const icon = this.props.iconPath ?
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
undefined;
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
undefined;
const classNames = ["mx_RoleButton"];
if (this.props.className) {

View file

@ -109,7 +109,7 @@ export default class AppTile extends React.Component {
const childContentProtocol = u.protocol;
if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') {
console.warn("Refusing to load mixed-content app:",
parentContentProtocol, childContentProtocol, window.location, this.props.app.url);
parentContentProtocol, childContentProtocol, window.location, this.props.app.url);
return true;
}
return false;

View file

@ -65,12 +65,18 @@ export class EditableItem extends React.Component {
<span className="mx_EditableItem_promptText">
{_t("Are you sure?")}
</span>
<AccessibleButton onClick={this._onActuallyRemove} kind="primary_sm"
className="mx_EditableItem_confirmBtn">
<AccessibleButton
onClick={this._onActuallyRemove}
kind="primary_sm"
className="mx_EditableItem_confirmBtn"
>
{_t("Yes")}
</AccessibleButton>
<AccessibleButton onClick={this._onDontRemove} kind="danger_sm"
className="mx_EditableItem_confirmBtn">
<AccessibleButton
onClick={this._onDontRemove}
kind="danger_sm"
className="mx_EditableItem_confirmBtn"
>
{_t("No")}
</AccessibleButton>
</div>
@ -121,11 +127,15 @@ export default class EditableItemList extends React.Component {
_renderNewItemField() {
return (
<form onSubmit={this._onItemAdded} autoComplete="off"
noValidate={true} className="mx_EditableItemList_newItem">
<form
onSubmit={this._onItemAdded}
autoComplete="off"
noValidate={true}
className="mx_EditableItemList_newItem"
>
<Field label={this.props.placeholder} type="text"
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
list={this.props.suggestionsListId} />
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
list={this.props.suggestionsListId} />
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}>
{_t("Add")}
</AccessibleButton>

View file

@ -221,13 +221,15 @@ export default class EditableText extends React.Component {
</div>;
} else {
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
editableEl = <div ref={this._editable_div}
contentEditable={true}
className={className}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur} />;
editableEl = <div
ref={this._editable_div}
contentEditable={true}
className={className}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>;
}
return editableEl;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { HTMLAttributes } from "react";
import React, { HTMLAttributes, ReactNode, useContext } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { sortBy } from "lodash";
@ -24,6 +24,7 @@ import { _t } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap";
import TextWithTooltip from "../elements/TextWithTooltip";
import { useRoomMembers } from "../../../hooks/useRoomMembers";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
const DEFAULT_NUM_FACES = 5;
@ -36,6 +37,7 @@ interface IProps extends HTMLAttributes<HTMLSpanElement> {
const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;
const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
let members = useRoomMembers(room);
// sort users with an explicit avatar first
@ -46,21 +48,42 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, .
// sort known users first
iteratees.unshift(member => isKnownMember(member));
}
if (members.length < 1) return null;
const shownMembers = sortBy(members, iteratees).slice(0, numShown);
// exclude ourselves from the shown members list
const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown);
if (shownMembers.length < 1) return null;
// We reverse the order of the shown faces in CSS to simplify their visual overlap,
// reverse members in tooltip order to make the order between the two match up.
const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", ");
let tooltip: ReactNode;
if (props.onClick) {
tooltip = <div>
<div className="mx_Tooltip_title">
{ _t("View all %(count)s members", { count: members.length }) }
</div>
<div className="mx_Tooltip_sub">
{ _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }) }
</div>
</div>;
} else {
tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", {
count: members.length,
commaSeparatedMembers,
});
}
return <div {...props} className="mx_FacePile">
<div className="mx_FacePile_faces">
{ shownMembers.map(member => {
return <TextWithTooltip key={member.userId} tooltip={member.name}>
<MemberAvatar member={member} width={28} height={28} />
</TextWithTooltip>;
}) }
</div>
{ onlyKnownUsers && <span>
<TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
{ members.length > numShown ? <span className="mx_FacePile_face mx_FacePile_more" /> : null }
{ shownMembers.map(m =>
<MemberAvatar key={m.userId} member={m} width={28} height={28} className="mx_FacePile_face" /> )}
</TextWithTooltip>
{ onlyKnownUsers && <span className="mx_FacePile_summary">
{ _t("%(count)s people you know have already joined", { count: members.length }) }
</span> }
</div>
</div>;
};
export default FacePile;

View file

@ -262,7 +262,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
tooltipClassName={classNames("mx_Field_tooltip", tooltipClassName)}
visible={(this.state.focused && this.props.forceTooltipVisible) || this.state.feedbackVisible}
label={tooltipContent || this.state.feedback}
forceOnRight
alignment={Tooltip.Alignment.Right}
/>;
}

View file

@ -32,17 +32,17 @@ import dis from '../../../dispatcher/dispatcher';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {normalizeWheelEvent} from "../../../utils/Mouse";
const MIN_ZOOM = 100;
const MAX_ZOOM = 300;
// Max scale to keep gaps around the image
const MAX_SCALE = 0.95;
// This is used for the buttons
const ZOOM_STEP = 10;
const ZOOM_STEP = 0.10;
// This is used for mouse wheel events
const ZOOM_COEFFICIENT = 10;
const ZOOM_COEFFICIENT = 0.0025;
// If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10;
interface IProps {
src: string, // the source of the image being displayed
name?: string, // the main title ('name') for the image
@ -61,8 +61,10 @@ interface IProps {
}
interface IState {
rotation: number,
zoom: number,
minZoom: number,
maxZoom: number,
rotation: number,
translationX: number,
translationY: number,
moving: boolean,
@ -74,8 +76,10 @@ export default class ImageView extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
zoom: 0,
minZoom: MAX_SCALE,
maxZoom: MAX_SCALE,
rotation: 0,
zoom: MIN_ZOOM,
translationX: 0,
translationY: 0,
moving: false,
@ -86,6 +90,8 @@ export default class ImageView extends React.Component<IProps, IState> {
// XXX: Refs to functional components
private contextMenuButton = createRef<any>();
private focusLock = createRef<any>();
private imageWrapper = createRef<HTMLDivElement>();
private image = createRef<HTMLImageElement>();
private initX = 0;
private initY = 0;
@ -98,12 +104,84 @@ export default class ImageView extends React.Component<IProps, IState> {
// We have to use addEventListener() because the listener
// needs to be passive in order to work with Chromium
this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false });
// We want to recalculate zoom whenever the window's size changes
window.addEventListener("resize", this.calculateZoom);
// After the image loads for the first time we want to calculate the zoom
this.image.current.addEventListener("load", this.calculateZoom);
}
componentWillUnmount() {
this.focusLock.current.removeEventListener('wheel', this.onWheel);
window.removeEventListener("resize", this.calculateZoom);
this.image.current.removeEventListener("load", this.calculateZoom);
}
private calculateZoom = () => {
const image = this.image.current;
const imageWrapper = this.imageWrapper.current;
const zoomX = imageWrapper.clientWidth / image.naturalWidth;
const zoomY = imageWrapper.clientHeight / image.naturalHeight;
// If the image is smaller in both dimensions set its the zoom to 1 to
// display it in its original size
if (zoomX >= 1 && zoomY >= 1) {
this.setState({
zoom: 1,
minZoom: 1,
maxZoom: 1,
});
return;
}
// We set minZoom to the min of the zoomX and zoomY to avoid overflow in
// any direction. We also multiply by MAX_SCALE to get a gap around the
// image by default
const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE;
if (this.state.zoom <= this.state.minZoom) this.setState({zoom: minZoom});
this.setState({
minZoom: minZoom,
maxZoom: 1,
});
}
private zoom(delta: number) {
const newZoom = this.state.zoom + delta;
if (newZoom <= this.state.minZoom) {
this.setState({
zoom: this.state.minZoom,
translationX: 0,
translationY: 0,
});
return;
}
if (newZoom >= this.state.maxZoom) {
this.setState({zoom: this.state.maxZoom});
return;
}
this.setState({
zoom: newZoom,
});
}
private onWheel = (ev: WheelEvent) => {
ev.stopPropagation();
ev.preventDefault();
const {deltaY} = normalizeWheelEvent(ev);
this.zoom(-(deltaY * ZOOM_COEFFICIENT));
};
private onZoomInClick = () => {
this.zoom(ZOOM_STEP);
};
private onZoomOutClick = () => {
this.zoom(-ZOOM_STEP);
};
private onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === Key.ESCAPE) {
ev.stopPropagation();
@ -112,29 +190,6 @@ export default class ImageView extends React.Component<IProps, IState> {
}
};
private onWheel = (ev: WheelEvent) => {
ev.stopPropagation();
ev.preventDefault();
const newZoom = this.state.zoom - (ev.deltaY * ZOOM_COEFFICIENT);
if (newZoom <= MIN_ZOOM) {
this.setState({
zoom: MIN_ZOOM,
translationX: 0,
translationY: 0,
});
return;
}
if (newZoom >= MAX_ZOOM) {
this.setState({zoom: MAX_ZOOM});
return;
}
this.setState({
zoom: newZoom,
});
};
private onRotateCounterClockwiseClick = () => {
const cur = this.state.rotation;
const rotationDegrees = cur - 90;
@ -147,31 +202,6 @@ export default class ImageView extends React.Component<IProps, IState> {
this.setState({ rotation: rotationDegrees });
};
private onZoomInClick = () => {
if (this.state.zoom >= MAX_ZOOM) {
this.setState({zoom: MAX_ZOOM});
return;
}
this.setState({
zoom: this.state.zoom + ZOOM_STEP,
});
};
private onZoomOutClick = () => {
if (this.state.zoom <= MIN_ZOOM) {
this.setState({
zoom: MIN_ZOOM,
translationX: 0,
translationY: 0,
});
return;
}
this.setState({
zoom: this.state.zoom - ZOOM_STEP,
});
};
private onDownloadClick = () => {
const a = document.createElement("a");
a.href = this.props.src;
@ -209,9 +239,13 @@ export default class ImageView extends React.Component<IProps, IState> {
ev.stopPropagation();
ev.preventDefault();
// Don't do anything if we pressed any
// other button than the left one
if (ev.button !== 0) return;
// Zoom in if we are completely zoomed out
if (this.state.zoom === MIN_ZOOM) {
this.setState({zoom: MAX_ZOOM});
if (this.state.zoom === this.state.minZoom) {
this.setState({zoom: this.state.maxZoom});
return;
}
@ -244,7 +278,7 @@ export default class ImageView extends React.Component<IProps, IState> {
Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE
) {
this.setState({
zoom: MIN_ZOOM,
zoom: this.state.minZoom,
translationX: 0,
translationY: 0,
});
@ -279,17 +313,20 @@ export default class ImageView extends React.Component<IProps, IState> {
render() {
const showEventMeta = !!this.props.mxEvent;
const zoomingDisabled = this.state.maxZoom === this.state.minZoom;
let cursor;
if (this.state.moving) {
cursor= "grabbing";
} else if (this.state.zoom === MIN_ZOOM) {
} else if (zoomingDisabled) {
cursor = "default";
} else if (this.state.zoom === this.state.minZoom) {
cursor = "zoom-in";
} else {
cursor = "zoom-out";
}
const rotationDegrees = this.state.rotation + "deg";
const zoomPercentage = this.state.zoom/100;
const zoom = this.state.zoom;
const translatePixelsX = this.state.translationX + "px";
const translatePixelsY = this.state.translationY + "px";
// The order of the values is important!
@ -301,7 +338,7 @@ export default class ImageView extends React.Component<IProps, IState> {
transition: this.state.moving ? null : "transform 200ms ease 0s",
transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY})
scale(${zoomPercentage})
scale(${zoom})
rotate(${rotationDegrees})`,
};
@ -373,6 +410,25 @@ export default class ImageView extends React.Component<IProps, IState> {
);
}
let zoomOutButton;
let zoomInButton;
if (!zoomingDisabled) {
zoomOutButton = (
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomOut"
title={_t("Zoom out")}
onClick={this.onZoomOutClick}>
</AccessibleTooltipButton>
);
zoomInButton = (
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomIn"
title={_t("Zoom in")}
onClick={ this.onZoomInClick }>
</AccessibleTooltipButton>
);
}
return (
<FocusLock
returnFocus={true}
@ -396,16 +452,8 @@ export default class ImageView extends React.Component<IProps, IState> {
title={_t("Rotate Left")}
onClick={ this.onRotateCounterClockwiseClick }>
</AccessibleTooltipButton>
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomOut"
title={_t("Zoom out")}
onClick={ this.onZoomOutClick }>
</AccessibleTooltipButton>
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomIn"
title={_t("Zoom in")}
onClick={ this.onZoomInClick }>
</AccessibleTooltipButton>
{zoomOutButton}
{zoomInButton}
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_download"
title={_t("Download")}
@ -420,11 +468,14 @@ export default class ImageView extends React.Component<IProps, IState> {
{this.renderContextMenu()}
</div>
</div>
<div className="mx_ImageView_image_wrapper">
<div
className="mx_ImageView_image_wrapper"
ref={this.imageWrapper}>
<img
src={this.props.src}
title={this.props.name}
style={style}
ref={this.image}
className="mx_ImageView_image"
draggable={true}
onMouseDown={this.onStartMoving}

View file

@ -18,8 +18,8 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';
import Tooltip from './Tooltip';
import { _t } from "../../../languageHandler";
import Tooltip, {Alignment} from './Tooltip';
import {_t} from "../../../languageHandler";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface ITooltipProps {
@ -61,7 +61,7 @@ export default class InfoTooltip extends React.PureComponent<ITooltipProps, ISta
className="mx_InfoTooltip_container"
tooltipClassName={classNames("mx_InfoTooltip_tooltip", tooltipClassName)}
label={tooltip || title}
forceOnRight={true}
alignment={Alignment.Right}
/> : <div />;
return (
<div onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className="mx_InfoTooltip">

View file

@ -46,8 +46,12 @@ export default class LabelledToggleSwitch extends React.Component {
// This is a minimal version of a SettingsFlag
let firstPart = <span className="mx_SettingsFlag_label">{this.props.label}</span>;
let secondPart = <ToggleSwitch checked={this.props.value} disabled={this.props.disabled}
onChange={this.props.onChange} aria-label={this.props.label} />;
let secondPart = <ToggleSwitch
checked={this.props.value}
disabled={this.props.disabled}
onChange={this.props.onChange}
aria-label={this.props.label}
/>;
if (this.props.toggleInFront) {
const temp = firstPart;

View file

@ -60,10 +60,10 @@ export default class LanguageDropdown extends React.Component {
// doesn't know this, therefore we do this.
const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
if (language) {
this.props.onOptionChange(language);
this.props.onOptionChange(language);
} else {
const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser());
this.props.onOptionChange(language);
const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser());
this.props.onOptionChange(language);
}
}
}

View file

@ -139,6 +139,8 @@ export default class PersistedElement extends React.Component {
_onAction(payload) {
if (payload.action === 'timeline_resize') {
this._repositionChild();
} else if (payload.action === 'logout') {
PersistedElement.destroyElement(this.props.persistKey);
}
}

View file

@ -225,19 +225,19 @@ class Pill extends React.Component {
}
break;
case Pill.TYPE_USER_MENTION: {
// If this user is not a member of this room, default to the empty member
const member = this.state.member;
if (member) {
userId = member.userId;
member.rawDisplayName = member.rawDisplayName || '';
linkText = member.rawDisplayName;
if (this.props.shouldShowPillAvatar) {
avatar = <MemberAvatar member={member} width={16} height={16} aria-hidden="true" />;
}
pillClass = 'mx_UserPill';
href = null;
onClick = this.onUserPillClicked;
// If this user is not a member of this room, default to the empty member
const member = this.state.member;
if (member) {
userId = member.userId;
member.rawDisplayName = member.rawDisplayName || '';
linkText = member.rawDisplayName;
if (this.props.shouldShowPillAvatar) {
avatar = <MemberAvatar member={member} width={16} height={16} aria-hidden="true" />;
}
pillClass = 'mx_UserPill';
href = null;
onClick = this.onUserPillClicked;
}
}
break;
case Pill.TYPE_ROOM_MENTION: {

View file

@ -135,9 +135,13 @@ export default class PowerSelector extends React.Component {
if (this.state.custom) {
picker = (
<Field type="number"
label={label} max={this.props.maxValue}
onBlur={this.onCustomBlur} onKeyDown={this.onCustomKeyDown} onChange={this.onCustomChange}
value={String(this.state.customValue)} disabled={this.props.disabled} />
label={label} max={this.props.maxValue}
onBlur={this.onCustomBlur}
onKeyDown={this.onCustomKeyDown}
onChange={this.onCustomChange}
value={String(this.state.customValue)}
disabled={this.props.disabled}
/>
);
} else {
// Each level must have a definition in this.state.levelRoleMap
@ -154,8 +158,9 @@ export default class PowerSelector extends React.Component {
picker = (
<Field element="select"
label={label} onChange={this.onSelectChange}
value={String(this.state.selectValue)} disabled={this.props.disabled}>
label={label} onChange={this.onSelectChange}
value={String(this.state.selectValue)} disabled={this.props.disabled}
>
{options}
</Field>
);

View file

@ -46,17 +46,18 @@ export default class RoomAliasField extends React.PureComponent {
const domain = (<span title={aliasPostfix}>{aliasPostfix}</span>);
const maxlength = 255 - this.props.domain.length - 2; // 2 for # and :
return (
<Field
label={_t("Room address")}
className="mx_RoomAliasField"
prefixComponent={poundSign}
postfixComponent={domain}
ref={ref => this._fieldRef = ref}
onValidate={this._onValidate}
placeholder={_t("e.g. my-room")}
onChange={this._onChange}
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength} />
<Field
label={_t("Room address")}
className="mx_RoomAliasField"
prefixComponent={poundSign}
postfixComponent={domain}
ref={ref => this._fieldRef = ref}
onValidate={this._onValidate}
placeholder={_t("e.g. my-room")}
onChange={this._onChange}
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength}
/>
);
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2021 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.
@ -67,7 +67,7 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }
</AccessibleButton>;
}
let serverName = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl;
let serverName: React.ReactNode = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl;
if (serverConfig.hsNameIsDifferent) {
serverName = <TextWithTooltip class="mx_Login_underlinedServerName" tooltip={serverConfig.hsUrl}>
{serverConfig.hsName}

View file

@ -25,6 +25,7 @@ export default class TextWithTooltip extends React.Component {
class: PropTypes.string,
tooltipClass: PropTypes.string,
tooltip: PropTypes.node.isRequired,
tooltipProps: PropTypes.object,
};
constructor() {
@ -46,15 +47,17 @@ export default class TextWithTooltip extends React.Component {
render() {
const Tooltip = sdk.getComponent("elements.Tooltip");
const {class: className, children, tooltip, tooltipClass, ...props} = this.props;
const {class: className, children, tooltip, tooltipClass, tooltipProps, ...props} = this.props;
return (
<span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className={className}>
{children}
{this.state.hover && <Tooltip
{...tooltipProps}
label={tooltip}
tooltipClassName={tooltipClass}
className={"mx_TextWithTooltip_tooltip"} /> }
className={"mx_TextWithTooltip_tooltip"}
/> }
</span>
);
}

View file

@ -59,13 +59,13 @@ class TintableSvg extends React.Component {
render() {
return (
<object className={"mx_TintableSvg " + (this.props.className ? this.props.className : "")}
type="image/svg+xml"
data={this.props.src}
width={this.props.width}
height={this.props.height}
onLoad={this.onLoad}
tabIndex="-1"
/>
type="image/svg+xml"
data={this.props.src}
width={this.props.width}
height={this.props.height}
onLoad={this.onLoad}
tabIndex="-1"
/>
);
}
}

View file

@ -25,6 +25,14 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
const MIN_TOOLTIP_HEIGHT = 25;
export enum Alignment {
Natural, // Pick left or right
Left,
Right,
Top, // Centered
Bottom, // Centered
}
interface IProps {
// Class applied to the element used to position the tooltip
className?: string;
@ -36,7 +44,7 @@ interface IProps {
visible?: boolean;
// the react element to put into the tooltip
label: React.ReactNode;
forceOnRight?: boolean;
alignment?: Alignment; // defaults to Natural
yOffset?: number;
}
@ -46,10 +54,14 @@ export default class Tooltip extends React.Component<IProps> {
private tooltip: void | Element | Component<Element, any, any>;
private parent: Element;
// XXX: This is because some components (Field) are unable to `import` the Tooltip class,
// so we expose the Alignment options off of us statically.
public static readonly Alignment = Alignment;
public static readonly defaultProps = {
visible: true,
yOffset: 0,
alignment: Alignment.Natural,
};
// Create a wrapper for the tooltip outside the parent and attach it to the body element
@ -86,11 +98,35 @@ export default class Tooltip extends React.Component<IProps> {
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT);
}
style.top = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset + offset;
if (!this.props.forceOnRight && parentBox.right > window.innerWidth / 2) {
style.right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
} else {
style.left = parentBox.right + window.pageXOffset + 6;
const baseTop = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset;
const top = baseTop + offset;
const right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
const left = parentBox.right + window.pageXOffset + 6;
const horizontalCenter = parentBox.right - window.pageXOffset - (parentBox.width / 2);
switch (this.props.alignment) {
case Alignment.Natural:
if (parentBox.right > window.innerWidth / 2) {
style.right = right;
style.top = top;
break;
}
// fall through to Right
case Alignment.Right:
style.left = left;
style.top = top;
break;
case Alignment.Left:
style.right = right;
style.top = top;
break;
case Alignment.Top:
style.top = baseTop - 16;
style.left = horizontalCenter;
break;
case Alignment.Bottom:
style.top = baseTop + parentBox.height;
style.left = horizontalCenter;
break;
}
return style;

View file

@ -178,9 +178,15 @@ export default class GroupMemberList extends React.Component {
}
const inputBox = (
<input className="mx_GroupMemberList_query mx_textinput" id="mx_GroupMemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community members')} autoComplete="off" />
<input
className="mx_GroupMemberList_query mx_textinput"
id="mx_GroupMemberList_query"
type="text"
onChange={this.onSearchQueryChanged}
value={this.state.searchQuery}
placeholder={_t('Filter community members')}
autoComplete="off"
/>
);
const joined = this.state.members ? <div className="mx_MemberList_joined">

View file

@ -67,11 +67,11 @@ export default class GroupPublicityToggle extends React.Component {
const GroupTile = sdk.getComponent('groups.GroupTile');
return <div className="mx_GroupPublicity_toggle">
<GroupTile groupId={this.props.groupId} showDescription={false}
avatarHeight={40} draggable={false}
avatarHeight={40} draggable={false}
/>
<ToggleSwitch checked={this.state.isGroupPublicised}
disabled={!this.state.ready || this.state.busy}
onChange={this._onPublicityToggle} />
disabled={!this.state.ready || this.state.busy}
onChange={this._onPublicityToggle} />
</div>;
}
}

View file

@ -141,9 +141,14 @@ export default class GroupRoomList extends React.Component {
);
}
const inputBox = (
<input className="mx_GroupRoomList_query mx_textinput" id="mx_GroupRoomList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community rooms')} autoComplete="off" />
<input
className="mx_GroupRoomList_query mx_textinput" id="mx_GroupRoomList_query"
type="text"
onChange={this.onSearchQueryChanged}
value={this.state.searchQuery}
placeholder={_t('Filter community rooms')}
autoComplete="off"
/>
);
const TruncatedList = sdk.getComponent("elements.TruncatedList");
@ -152,7 +157,7 @@ export default class GroupRoomList extends React.Component {
{ inviteButton }
<AutoHideScrollbar className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
<TruncatedList className="mx_GroupRoomList_wrapper" truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
createOverflowElement={this._createOverflowTile}>
{ this.makeGroupRoomTiles(this.state.searchQuery) }
</TruncatedList>
</AutoHideScrollbar>

View file

@ -160,7 +160,6 @@ export default class EditHistoryMessage extends React.PureComponent {
"mx_EventTile": true,
// Note: we keep the `sending` state class for tests, not for our styles
"mx_EventTile_sending": isSending,
"mx_EventTile_notSent": this.state.sendStatus === 'not_sent',
});
return (
<li>

View file

@ -125,9 +125,9 @@ export default class MImageBody extends React.Component {
_isGif() {
const content = this.props.mxEvent.getContent();
return (
content &&
content.info &&
content.info.mimetype === "image/gif"
content &&
content.info &&
content.info.mimetype === "image/gif"
);
}
@ -185,9 +185,8 @@ export default class MImageBody extends React.Component {
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
// thumbnail resolution will be unnecessarily reduced.
// custom timeline widths seems preferable.
const pixelRatio = window.devicePixelRatio;
const thumbWidth = Math.round(800 * pixelRatio);
const thumbHeight = Math.round(600 * pixelRatio);
const thumbWidth = 800;
const thumbHeight = 600;
const content = this.props.mxEvent.getContent();
const media = mediaFromContent(content);
@ -218,7 +217,7 @@ export default class MImageBody extends React.Component {
const info = content.info;
if (
this._isGif() ||
pixelRatio === 1.0 ||
window.devicePixelRatio === 1.0 ||
(!info || !info.w || !info.h || !info.size)
) {
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
@ -347,9 +346,9 @@ export default class MImageBody extends React.Component {
} else {
imageElement = (
<img style={{display: 'none'}} src={thumbUrl} ref={this._image}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
/>
);
}
@ -385,12 +384,12 @@ export default class MImageBody extends React.Component {
// mx_MImageBody_thumbnail resizes img to exactly container size
img = (
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this._image}
style={{ maxWidth: maxWidth + "px" }}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
style={{ maxWidth: maxWidth + "px" }}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
);
}
@ -468,9 +467,9 @@ export default class MImageBody extends React.Component {
const contentUrl = this._getContentUrl();
let thumbUrl;
if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
thumbUrl = contentUrl;
thumbUrl = contentUrl;
} else {
thumbUrl = this._getThumbUrl();
thumbUrl = this._getThumbUrl();
}
const thumbnail = this._messageContent(contentUrl, thumbUrl, content);

View file

@ -82,9 +82,7 @@ export default class MKeyVerificationConclusion extends React.Component {
}
// User isn't actually verified
if (!MatrixClientPeg.get()
.checkUserTrust(request.otherUserId)
.isCrossSigningVerified()) {
if (!MatrixClientPeg.get().checkUserTrust(request.otherUserId).isCrossSigningVerified()) {
return false;
}

View file

@ -0,0 +1,106 @@
/*
Copyright 2021 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 {replaceableComponent} from "../../../utils/replaceableComponent";
import {Playback} from "../../../voice/Playback";
import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner';
import {_t} from "../../../languageHandler";
import {mediaFromContent} from "../../../customisations/Media";
import {decryptFile} from "../../../utils/DecryptFile";
import RecordingPlayback from "../voice_messages/RecordingPlayback";
interface IProps {
mxEvent: MatrixEvent;
}
interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}
@replaceableComponent("views.messages.MVoiceMessageBody")
export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {};
}
public async componentDidMount() {
let buffer: ArrayBuffer;
const content = this.props.mxEvent.getContent();
const media = mediaFromContent(content);
if (media.isEncrypted) {
try {
const blob = await decryptFile(content.file);
buffer = await blob.arrayBuffer();
this.setState({decryptedBlob: blob});
} catch (e) {
this.setState({error: e});
console.warn("Unable to decrypt voice message", e);
return; // stop processing the audio file
}
} else {
try {
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
} catch (e) {
this.setState({error: e});
console.warn("Unable to download voice message", e);
return; // stop processing the audio file
}
}
const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
// We should have a buffer to work with now: let's set it up
const playback = new Playback(buffer, waveform);
this.setState({playback});
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
}
public render() {
if (this.state.error) {
// TODO: @@TR: Verify error state
return (
<span className="mx_MVoiceMessageBody">
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
{ _t("Error processing voice message") }
</span>
);
}
if (!this.state.playback) {
// TODO: @@TR: Verify loading/decrypting state
return (
<span className="mx_MVoiceMessageBody">
<InlineSpinner />
</span>
);
}
// At this point we should have a playable state
return (
<span className="mx_MVoiceMessageBody">
<RecordingPlayback playback={this.state.playback} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
</span>
)
}
}

View file

@ -0,0 +1,39 @@
/*
Copyright 2021 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 MAudioBody from "./MAudioBody";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import MVoiceMessageBody from "./MVoiceMessageBody";
interface IProps {
mxEvent: MatrixEvent;
}
@replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
public render() {
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'];
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages");
if (isVoiceMessage && voiceMessagesEnabled) {
return <MVoiceMessageBody {...this.props} />;
} else {
return <MAudioBody {...this.props} />;
}
}
}

View file

@ -29,6 +29,8 @@ import RoomContext from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar";
import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {canCancel} from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend";
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -169,45 +171,118 @@ export default class MessageActionBar extends React.PureComponent {
});
};
render() {
let reactButton;
let replyButton;
let editButton;
/**
* Runs a given fn on the set of possible events to test. The first event
* that passes the checkFn will have fn executed on it. Both functions take
* a MatrixEvent object. If no particular conditions are needed, checkFn can
* be null/undefined. If no functions pass the checkFn, no action will be
* taken.
* @param {Function} fn The execution function.
* @param {Function} checkFn The test function.
*/
runActionOnFailedEv(fn, checkFn) {
if (!checkFn) checkFn = () => true;
if (isContentActionable(this.props.mxEvent)) {
if (this.context.canReact) {
reactButton = (
<ReactButton mxEvent={this.props.mxEvent} reactions={this.props.reactions} onFocusChange={this.onFocusChange} />
);
}
if (this.context.canReply) {
replyButton = <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
/>;
const mxEvent = this.props.mxEvent;
const editEvent = mxEvent.replacingEvent();
const redactEvent = mxEvent.localRedactionEvent();
const tryOrder = [redactEvent, editEvent, mxEvent];
for (const ev of tryOrder) {
if (ev && checkFn(ev)) {
fn(ev);
break;
}
}
}
onResendClick = (ev) => {
this.runActionOnFailedEv((tarEv) => Resend.resend(tarEv));
};
onCancelClick = (ev) => {
this.runActionOnFailedEv(
(tarEv) => Resend.removeFromQueue(tarEv),
(testEv) => canCancel(testEv.status),
);
};
render() {
const toolbarOpts = [];
if (canEditContent(this.props.mxEvent)) {
editButton = <RovingAccessibleTooltipButton
toolbarOpts.push(<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
title={_t("Edit")}
onClick={this.onEditClick}
/>;
key="edit"
/>);
}
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
{reactButton}
{replyButton}
{editButton}
<OptionsButton
const cancelSendingButton = <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_cancelButton"
title={_t("Delete")}
onClick={this.onCancelClick}
key="cancel"
/>;
// We show a different toolbar for failed events, so detect that first.
const mxEvent = this.props.mxEvent;
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const allowCancel = canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus);
const isFailed = [mxEvent.status, editStatus, redactStatus].includes("not_sent");
if (allowCancel && isFailed) {
// The resend button needs to appear ahead of the edit button, so insert to the
// start of the opts
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_resendButton"
title={_t("Retry")}
onClick={this.onResendClick}
key="resend"
/>);
// The delete button should appear last, so we can just drop it at the end
toolbarOpts.push(cancelSendingButton);
} else {
if (isContentActionable(this.props.mxEvent)) {
// Like the resend button, the react and reply buttons need to appear before the edit.
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply) {
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
key="reply"
/>);
}
if (this.context.canReact) {
toolbarOpts.splice(0, 0, <ReactButton
mxEvent={this.props.mxEvent}
reactions={this.props.reactions}
onFocusChange={this.onFocusChange}
key="react"
/>);
}
}
if (allowCancel) {
toolbarOpts.push(cancelSendingButton);
}
// The menu button should be last, so dump it there.
toolbarOpts.push(<OptionsButton
mxEvent={this.props.mxEvent}
getReplyThread={this.props.getReplyThread}
getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange}
/>
key="menu"
/>);
}
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
{toolbarOpts}
</Toolbar>;
}
}

View file

@ -72,12 +72,8 @@ export default class MessageEvent extends React.Component {
'm.emote': sdk.getComponent('messages.TextualBody'),
'm.image': sdk.getComponent('messages.MImageBody'),
'm.file': sdk.getComponent('messages.MFileBody'),
'm.audio': sdk.getComponent('messages.MAudioBody'),
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
'm.video': sdk.getComponent('messages.MVideoBody'),
// TODO: @@ TravisR: Use labs flag determination.
// MSC: https://github.com/matrix-org/matrix-doc/pull/2516
'org.matrix.msc2516.voice': sdk.getComponent('messages.MAudioBody'),
};
const evTypes = {
'm.sticker': sdk.getComponent('messages.MStickerBody'),

View file

@ -129,12 +129,13 @@ export default class ReactionsRowButton extends React.PureComponent {
},
);
}
const isPeeking = room.getMyMembership() !== "join";
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <AccessibleButton
className={classes}
aria-label={label}
onClick={this.onClick}
disabled={isPeeking}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>

View file

@ -521,11 +521,12 @@ export default class TextualBody extends React.Component {
const LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget');
widgets = this.state.links.map((link)=>{
return <LinkPreviewWidget
key={link}
link={link}
mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick}
onHeightChanged={this.props.onHeightChanged} />;
key={link}
link={link}
mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick}
onHeightChanged={this.props.onHeightChanged}
/>;
});
}

View file

@ -187,9 +187,15 @@ function DeviceItem({userId, device}: {userId: string, device: IDevice}) {
verifyDevice(cli.getUser(userId), device);
};
const deviceName = device.ambiguous ?
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
device.getDisplayName();
let deviceName;
if (!device.getDisplayName()?.trim()) {
deviceName = device.deviceId;
} else {
deviceName = device.ambiguous ?
device.getDisplayName() + " (" + device.deviceId + ")" :
device.getDisplayName();
}
let trustedLabel = null;
if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted");
@ -440,7 +446,7 @@ const UserOptionsSection: React.FC<{
);
};
const warnSelfDemote = async (isSpace) => {
const warnSelfDemote = async (isSpace: boolean) => {
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
title: _t("Demote yourself?"),
description:
@ -727,7 +733,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({member, room, powerLevels,
// if muting self, warn as it may be irreversible
if (target === cli.getUserId()) {
try {
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return;
} catch (e) {
console.error("Failed to warn about self demotion: ", e);
return;
@ -816,7 +822,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) {
if (me.powerLevel >= redactPowerLevel && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
);
@ -1095,7 +1101,7 @@ const PowerLevelEditor: React.FC<{
} else if (myUserId === target) {
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
try {
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return;
} catch (e) {
console.error("Failed to warn about self demotion: ", e);
}
@ -1325,10 +1331,10 @@ const BasicUserInfo: React.FC<{
if (!isRoomEncrypted) {
if (!cryptoEnabled) {
text = _t("This client does not support end-to-end encryption.");
} else if (room && !room.isSpaceRoom()) {
} else if (room && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
text = _t("Messages in this room are not end-to-end encrypted.");
}
} else if (!room.isSpaceRoom()) {
} else if (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom()) {
text = _t("Messages in this room are end-to-end encrypted.");
}
@ -1405,7 +1411,7 @@ const BasicUserInfo: React.FC<{
canInvite={roomPermissions.canInvite}
isIgnored={isIgnored}
member={member}
isSpace={room?.isSpaceRoom()}
isSpace={SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()}
/>
{ adminToolsContainer }
@ -1567,7 +1573,7 @@ const UserInfo: React.FC<Props> = ({
previousPhase = RightPanelPhases.RoomMemberInfo;
refireParams = {member: member};
} else if (room) {
previousPhase = previousPhase = room.isSpaceRoom()
previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()
? RightPanelPhases.SpaceMemberList
: RightPanelPhases.RoomMemberList;
}
@ -1616,7 +1622,7 @@ const UserInfo: React.FC<Props> = ({
}
let scopeHeader;
if (room?.isSpaceRoom()) {
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} />

View file

@ -310,9 +310,14 @@ export default class AliasSettings extends React.Component {
let found = false;
const canonicalValue = this.state.canonicalAlias || "";
const canonicalAliasSection = (
<Field onChange={this.onCanonicalAliasChange} value={canonicalValue}
disabled={this.state.updatingCanonicalAlias || !this.props.canSetCanonicalAlias}
element='select' id='canonicalAlias' label={_t('Main address')}>
<Field
onChange={this.onCanonicalAliasChange}
value={canonicalValue}
disabled={this.state.updatingCanonicalAlias || !this.props.canSetCanonicalAlias}
element='select'
id='canonicalAlias'
label={_t('Main address')}
>
<option value="" key="unset">{ _t('not specified') }</option>
{
this._getAliases().map((alias, i) => {
@ -326,9 +331,9 @@ export default class AliasSettings extends React.Component {
}
{
found || !this.state.canonicalAlias ? '' :
<option value={ this.state.canonicalAlias } key='arbitrary'>
{ this.state.canonicalAlias }
</option>
<option value={ this.state.canonicalAlias } key='arbitrary'>
{ this.state.canonicalAlias }
</option>
}
</Field>
);

View file

@ -205,16 +205,34 @@ export default class RoomProfileSettings extends React.Component {
noValidate={true}
className="mx_ProfileSettings_profileForm"
>
<input type="file" ref={this._avatarUpload} className="mx_ProfileSettings_avatarUpload"
onChange={this._onAvatarChanged} accept="image/*" />
<input
type="file"
ref={this._avatarUpload}
className="mx_ProfileSettings_avatarUpload"
onChange={this._onAvatarChanged}
accept="image/*"
/>
<div className="mx_ProfileSettings_profile">
<div className="mx_ProfileSettings_controls">
<Field label={_t("Room Name")}
type="text" value={this.state.displayName} autoComplete="off"
onChange={this._onDisplayNameChanged} disabled={!this.state.canSetName} />
<Field className="mx_ProfileSettings_controls_topic" id="profileTopic" label={_t("Room Topic")} disabled={!this.state.canSetTopic}
type="text" value={this.state.topic} autoComplete="off"
onChange={this._onTopicChanged} element="textarea" />
<Field
label={_t("Room Name")}
type="text"
value={this.state.displayName}
autoComplete="off"
onChange={this._onDisplayNameChanged}
disabled={!this.state.canSetName}
/>
<Field
className="mx_ProfileSettings_controls_topic"
id="profileTopic"
label={_t("Room Topic")}
disabled={!this.state.canSetTopic}
type="text"
value={this.state.topic}
autoComplete="off"
onChange={this._onTopicChanged}
element="textarea"
/>
</div>
<AvatarSetting
avatarUrl={this.state.avatarUrl}

View file

@ -68,10 +68,12 @@ export default class UrlPreviewSettings extends React.Component {
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, "room")) {
previewsForRoom = (
<label>
<SettingsFlag name="urlPreviewsEnabled"
level={SettingLevel.ROOM}
roomId={roomId}
isExplicit={true} />
<SettingsFlag
name="urlPreviewsEnabled"
level={SettingLevel.ROOM}
roomId={roomId}
isExplicit={true}
/>
</label>
);
} else {
@ -91,8 +93,8 @@ export default class UrlPreviewSettings extends React.Component {
const previewsForRoomAccount = ( // in an e2ee room we use a special key to enforce per-room opt-in
<SettingsFlag name={isEncrypted ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'}
level={SettingLevel.ROOM_ACCOUNT}
roomId={roomId} />
level={SettingLevel.ROOM_ACCOUNT}
roomId={roomId} />
);
return (

View file

@ -140,7 +140,12 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}
public componentDidUpdate(prevProps: IProps) {
if (this.props.placeholder !== prevProps.placeholder && this.props.placeholder) {
// We need to re-check the placeholder when the enabled state changes because it causes the
// placeholder element to remount, which gets rid of the `::before` class. Re-evaluating the
// placeholder means we get a proper `::before` with the placeholder.
const enabledChange = this.props.disabled !== prevProps.disabled;
const placeholderChanged = this.props.placeholder !== prevProps.placeholder;
if (this.props.placeholder && (placeholderChanged || enabledChange)) {
const {isEmpty} = this.props.model;
if (isEmpty) {
this.showPlaceholder();
@ -670,8 +675,6 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
});
const classes = classNames("mx_BasicMessageComposer_input", {
"mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar,
// TODO: @@ TravisR: This doesn't work properly. The composer resets in a strange way.
"mx_BasicMessageComposer_input_disabled": this.props.disabled,
});

View file

@ -176,8 +176,11 @@ class EntityTile extends React.Component {
// The wrapping div is required to make the magic mouse listener work, for some reason.
return (
<div ref={(c) => this.container = c} >
<AccessibleButton className={classNames(mainClassNames)} title={this.props.title}
onClick={this.props.onClick}>
<AccessibleButton
className={classNames(mainClassNames)}
title={this.props.title}
onClick={this.props.onClick}
>
<div className="mx_EntityTile_avatar">
{ av }
{ e2eIcon }

View file

@ -1,6 +1,6 @@
/*
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 - 2021 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.
@ -15,11 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import React from 'react';
import classNames from "classnames";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {EventStatus} from 'matrix-js-sdk/src/models/event';
import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler';
@ -27,7 +29,7 @@ import * as TextForEvent from "../../../TextForEvent";
import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import {Layout, LayoutPropType} from "../../../settings/Layout";
import {Layout} from "../../../settings/Layout";
import {formatTime} from "../../../DateUtils";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
@ -40,6 +42,10 @@ import {WIDGET_LAYOUT_EVENT_TYPE} from "../../../stores/widgets/WidgetLayoutStor
import {objectHasDiff} from "../../../utils/objects";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
import { EditorStateTransfer } from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "./NotificationBadge";
const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
@ -169,98 +175,130 @@ const MAX_READ_AVATARS = 5;
// | '--------------------------------------' |
// '----------------------------------------------------------'
interface IReadReceiptProps {
userId: string;
roomMember: RoomMember;
ts: number;
}
interface IProps {
// the MatrixEvent to show
mxEvent: MatrixEvent;
// true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted()
// might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent
// references the same this.props.mxEvent.
isRedacted?: boolean;
// true if this is a continuation of the previous event (which has the
// effect of not showing another avatar/displayname
continuation?: boolean;
// true if this is the last event in the timeline (which has the effect
// of always showing the timestamp)
last?: boolean;
// true if the event is the last event in a section (adds a css class for
// targeting)
lastInSection?: boolean;
// True if the event is the last successful (sent) event.
lastSuccessful?: boolean;
// true if this is search context (which has the effect of greying out
// the text
contextual?: boolean;
// a list of words to highlight, ordered by longest first
highlights?: string[];
// link URL for the highlights
highlightLink?: string;
// should show URL previews for this event
showUrlPreview?: boolean;
// is this the focused event
isSelectedEvent?: boolean;
// callback called when dynamic content in events are loaded
onHeightChanged?: () => void;
// a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'.
readReceipts?: IReadReceiptProps[];
// opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations. Should be an empty object when the room
// first loads
readReceiptMap?: any;
// A function which is used to check if the parent panel is being
// unmounted, to avoid unnecessary work. Should return true if we
// are being unmounted.
checkUnmounting?: () => boolean;
// the status of this event - ie, mxEvent.status. Denormalised to here so
// that we can tell when it changes.
eventSendStatus?: string;
// the shape of the tile. by default, the layout is intended for the
// normal room timeline. alternative values are: "file_list", "file_grid"
// and "notif". This could be done by CSS, but it'd be horribly inefficient.
// It could also be done by subclassing EventTile, but that'd be quite
// boiilerplatey. So just make the necessary render decisions conditional
// for now.
tileShape?: 'notif' | 'file_grid' | 'reply' | 'reply_preview';
// show twelve hour timestamps
isTwelveHour?: boolean;
// helper function to access relations for this event
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
// whether to show reactions for this event
showReactions?: boolean;
// which layout to use
layout: Layout;
// whether or not to show flair at all
enableFlair?: boolean;
// whether or not to show read receipts
showReadReceipts?: boolean;
// Used while editing, to pass the event, and to preserve editor state
// from one editor instance to another when remounting the editor
// upon receiving the remote echo for an unsent event.
editState?: EditorStateTransfer;
// Event ID of the event replacing the content of this event, if any
replacingEventId?: string;
// Helper to build permalinks for the room
permalinkCreator?: RoomPermalinkCreator;
}
interface IState {
// Whether the action bar is focused.
actionBarFocused: boolean;
// Whether all read receipts are being displayed. If not, only display
// a truncation of them.
allReadAvatars: boolean;
// Whether the event's sender has been verified.
verified: string;
// Whether onRequestKeysClick has been called since mounting.
previouslyRequestedKeys: boolean;
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: Relations;
}
@replaceableComponent("views.rooms.EventTile")
export default class EventTile extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
/* true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted()
* might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent
* references the same this.props.mxEvent.
*/
isRedacted: PropTypes.bool,
/* true if this is a continuation of the previous event (which has the
* effect of not showing another avatar/displayname
*/
continuation: PropTypes.bool,
/* true if this is the last event in the timeline (which has the effect
* of always showing the timestamp)
*/
last: PropTypes.bool,
// true if the event is the last event in a section (adds a css class for
// targeting)
lastInSection: PropTypes.bool,
// True if the event is the last successful (sent) event.
isLastSuccessful: PropTypes.bool,
/* true if this is search context (which has the effect of greying out
* the text
*/
contextual: PropTypes.bool,
/* a list of words to highlight, ordered by longest first */
highlights: PropTypes.array,
/* link URL for the highlights */
highlightLink: PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: PropTypes.bool,
/* is this the focused event */
isSelectedEvent: PropTypes.bool,
/* callback called when dynamic content in events are loaded */
onHeightChanged: PropTypes.func,
/* a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'. */
readReceipts: PropTypes.arrayOf(PropTypes.object),
/* opaque readreceipt info for each userId; used by ReadReceiptMarker
* to manage its animations. Should be an empty object when the room
* first loads
*/
readReceiptMap: PropTypes.object,
/* A function which is used to check if the parent panel is being
* unmounted, to avoid unnecessary work. Should return true if we
* are being unmounted.
*/
checkUnmounting: PropTypes.func,
/* the status of this event - ie, mxEvent.status. Denormalised to here so
* that we can tell when it changes. */
eventSendStatus: PropTypes.string,
/* the shape of the tile. by default, the layout is intended for the
* normal room timeline. alternative values are: "file_list", "file_grid"
* and "notif". This could be done by CSS, but it'd be horribly inefficient.
* It could also be done by subclassing EventTile, but that'd be quite
* boiilerplatey. So just make the necessary render decisions conditional
* for now.
*/
tileShape: PropTypes.string,
// show twelve hour timestamps
isTwelveHour: PropTypes.bool,
// helper function to access relations for this event
getRelationsForEvent: PropTypes.func,
// whether to show reactions for this event
showReactions: PropTypes.bool,
// which layout to use
layout: LayoutPropType,
// whether or not to show flair at all
enableFlair: PropTypes.bool,
};
export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
private tile = React.createRef();
private replyThread = React.createRef();
static defaultProps = {
// no-op function because onHeightChanged is optional yet some sub-components assume its existence
@ -287,26 +325,22 @@ export default class EventTile extends React.Component {
};
// don't do RR animations until we are mounted
this._suppressReadReceiptAnimation = true;
this._tile = createRef();
this._replyThread = createRef();
this.suppressReadReceiptAnimation = true;
// Throughout the component we manage a read receipt listener to see if our tile still
// qualifies for a "sent" or "sending" state (based on their relevant conditions). We
// don't want to over-subscribe to the read receipt events being fired, so we use a flag
// to determine if we've already subscribed and use a combination of other flags to find
// out if we should even be subscribed at all.
this._isListeningForReceipts = false;
this.isListeningForReceipts = false;
}
/**
* When true, the tile qualifies for some sort of special read receipt. This could be a 'sending'
* or 'sent' receipt, for example.
* @returns {boolean}
* @private
*/
get _isEligibleForSpecialReceipt() {
private get isEligibleForSpecialReceipt() {
// First, if there are other read receipts then just short-circuit this.
if (this.props.readReceipts && this.props.readReceipts.length > 0) return false;
if (!this.props.mxEvent) return false;
@ -335,9 +369,9 @@ export default class EventTile extends React.Component {
return true;
}
get _shouldShowSentReceipt() {
private get shouldShowSentReceipt() {
// If we're not even eligible, don't show the receipt.
if (!this._isEligibleForSpecialReceipt) return false;
if (!this.isEligibleForSpecialReceipt) return false;
// We only show the 'sent' receipt on the last successful event.
if (!this.props.lastSuccessful) return false;
@ -355,9 +389,9 @@ export default class EventTile extends React.Component {
return true;
}
get _shouldShowSendingReceipt() {
private get shouldShowSendingReceipt() {
// If we're not even eligible, don't show the receipt.
if (!this._isEligibleForSpecialReceipt) return false;
if (!this.isEligibleForSpecialReceipt) return false;
// Check the event send status to see if we are pending. Null/undefined status means the
// message was sent, so check for that and 'sent' explicitly.
@ -371,22 +405,22 @@ export default class EventTile extends React.Component {
// TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
this._verifyEvent(this.props.mxEvent);
this.verifyEvent(this.props.mxEvent);
}
componentDidMount() {
this._suppressReadReceiptAnimation = false;
this.suppressReadReceiptAnimation = false;
const client = this.context;
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
this.props.mxEvent.on("Event.relationsCreated", this.onReactionsCreated);
}
if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) {
client.on("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = true;
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
client.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true;
}
}
@ -396,7 +430,7 @@ export default class EventTile extends React.Component {
// re-check the sender verification as outgoing events progress through
// the send process.
if (nextProps.eventSendStatus !== this.props.eventSendStatus) {
this._verifyEvent(nextProps.mxEvent);
this.verifyEvent(nextProps.mxEvent);
}
}
@ -405,35 +439,35 @@ export default class EventTile extends React.Component {
return true;
}
return !this._propsEqual(this.props, nextProps);
return !this.propsEqual(this.props, nextProps);
}
componentWillUnmount() {
const client = this.context;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
client.removeListener("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = false;
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
client.removeListener("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = false;
this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
this.props.mxEvent.removeListener("Event.relationsCreated", this.onReactionsCreated);
}
}
componentDidUpdate(prevProps, prevState, snapshot) {
// If we're not listening for receipts and expect to be, register a listener.
if (!this._isListeningForReceipts && (this._shouldShowSentReceipt || this._shouldShowSendingReceipt)) {
this.context.on("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = true;
if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) {
this.context.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true;
}
}
_onRoomReceipt = (ev, room) => {
private onRoomReceipt = (ev, room) => {
// ignore events for other rooms
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (room !== tileRoom) return;
if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt && !this._isListeningForReceipts) {
if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt && !this.isListeningForReceipts) {
return;
}
@ -441,36 +475,36 @@ export default class EventTile extends React.Component {
// the getters we use here to determine what needs rendering.
this.forceUpdate(() => {
// Per elsewhere in this file, we can remove the listener once we will have no further purpose for it.
if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt) {
this.context.removeListener("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = false;
if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt) {
this.context.removeListener("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = false;
}
});
};
/** called when the event is decrypted after we show it.
*/
_onDecrypted = () => {
private onDecrypted = () => {
// we need to re-verify the sending device.
// (we call onHeightChanged in _verifyEvent to handle the case where decryption
// (we call onHeightChanged in verifyEvent to handle the case where decryption
// has caused a change in size of the event tile)
this._verifyEvent(this.props.mxEvent);
this.verifyEvent(this.props.mxEvent);
this.forceUpdate();
};
onDeviceVerificationChanged = (userId, device) => {
private onDeviceVerificationChanged = (userId, device) => {
if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent);
this.verifyEvent(this.props.mxEvent);
}
};
onUserVerificationChanged = (userId, _trustStatus) => {
private onUserVerificationChanged = (userId, _trustStatus) => {
if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent);
this.verifyEvent(this.props.mxEvent);
}
};
async _verifyEvent(mxEvent) {
private async verifyEvent(mxEvent) {
if (!mxEvent.isEncrypted()) {
return;
}
@ -524,7 +558,7 @@ export default class EventTile extends React.Component {
}, this.props.onHeightChanged); // Decryption may have caused a change in size
}
_propsEqual(objA, objB) {
private propsEqual(objA, objB) {
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
@ -591,7 +625,7 @@ export default class EventTile extends React.Component {
};
getReadAvatars() {
if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) {
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
}
@ -638,7 +672,7 @@ export default class EventTile extends React.Component {
leftOffset={left} hidden={hidden}
readReceiptInfo={readReceiptInfo}
checkUnmounting={this.props.checkUnmounting}
suppressAnimation={this._suppressReadReceiptAnimation}
suppressAnimation={this.suppressReadReceiptAnimation}
onClick={this.toggleAllReadAvatars}
timestamp={receipt.ts}
showTwelveHour={this.props.isTwelveHour}
@ -695,7 +729,7 @@ export default class EventTile extends React.Component {
});
};
_renderE2EPadlock() {
private renderE2EPadlock() {
const ev = this.props.mxEvent;
// event could not be decrypted
@ -744,9 +778,9 @@ export default class EventTile extends React.Component {
});
};
getTile = () => this._tile.current;
getTile = () => this.tile.current;
getReplyThread = () => this._replyThread.current;
getReplyThread = () => this.replyThread.current;
getReactions = () => {
if (
@ -766,11 +800,11 @@ export default class EventTile extends React.Component {
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
};
_onReactionsCreated = (relationType, eventType) => {
private onReactionsCreated = (relationType, eventType) => {
if (relationType !== "m.annotation" || eventType !== "m.reaction") {
return;
}
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
this.props.mxEvent.removeListener("Event.relationsCreated", this.onReactionsCreated);
this.setState({
reactions: this.getReactions(),
});
@ -835,7 +869,6 @@ export default class EventTile extends React.Component {
mx_EventTile_12hr: this.props.isTwelveHour,
// Note: we keep the `sending` state class for tests, not for our styles
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent',
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
@ -858,8 +891,6 @@ export default class EventTile extends React.Component {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
const readAvatars = this.getReadAvatars();
let avatar;
let sender;
let avatarSize;
@ -894,7 +925,7 @@ export default class EventTile extends React.Component {
// so that the correct avatar is shown as the text is
// `$target accepted the invitation for $email`
if (this.props.mxEvent.getContent().third_party_invite) {
member = this.props.mxEvent.target;
member = this.props.mxEvent.target;
} else {
member = this.props.mxEvent.sender;
}
@ -911,8 +942,9 @@ export default class EventTile extends React.Component {
if (needsSenderProfile) {
if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') {
sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair} />;
mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair}
/>;
} else {
sender = <SenderProfile mxEvent={this.props.mxEvent} enableFlair={this.props.enableFlair} />;
}
@ -975,18 +1007,28 @@ export default class EventTile extends React.Component {
}
const linkedTimestamp = <a
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
>
{ timestamp }
</a>;
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
>
{ timestamp }
</a>;
const useIRCLayout = this.props.layout == Layout.IRC;
const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
const groupPadlock = !useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const ircPadlock = useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
let msgOption;
if (this.props.showReadReceipts) {
const readAvatars = this.getReadAvatars();
msgOption = (
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
);
}
switch (this.props.tileShape) {
case 'notif': {
@ -1007,12 +1049,13 @@ export default class EventTile extends React.Component {
</a>
</div>
<div className="mx_EventTile_line">
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} />
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged}
/>
</div>
</div>
);
@ -1021,13 +1064,14 @@ export default class EventTile extends React.Component {
return (
<div className={classes} aria-live={ariaLive} aria-atomic="true">
<div className="mx_EventTile_line">
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onHeightChanged={this.props.onHeightChanged} />
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onHeightChanged={this.props.onHeightChanged}
/>
</div>
<a
className="mx_EventTile_senderDetailsLink"
@ -1051,7 +1095,7 @@ export default class EventTile extends React.Component {
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this._replyThread,
this.replyThread,
);
}
return (
@ -1064,13 +1108,14 @@ export default class EventTile extends React.Component {
{ groupTimestamp }
{ groupPadlock }
{ thread }
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
replacingEventId={this.props.replacingEventId}
showUrlPreview={false} />
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
replacingEventId={this.props.replacingEventId}
showUrlPreview={false}
/>
</div>
</div>
);
@ -1080,7 +1125,7 @@ export default class EventTile extends React.Component {
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this._replyThread,
this.replyThread,
this.props.layout,
);
@ -1094,22 +1139,21 @@ export default class EventTile extends React.Component {
{ groupTimestamp }
{ groupPadlock }
{ thread }
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
replacingEventId={this.props.replacingEventId}
editState={this.props.editState}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
permalinkCreator={this.props.permalinkCreator}
onHeightChanged={this.props.onHeightChanged} />
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
replacingEventId={this.props.replacingEventId}
editState={this.props.editState}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
permalinkCreator={this.props.permalinkCreator}
onHeightChanged={this.props.onHeightChanged}
/>
{ keyRequestInfo }
{ reactionsRow }
{ actionBar }
</div>
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{msgOption}
{
// The avatar goes after the event tile as it's absolutely positioned to be over the
// event tile line, so needs to be later in the DOM so it appears on top (this avoids
@ -1173,18 +1217,26 @@ function E2ePadlockUnknown(props) {
function E2ePadlockUnauthenticated(props) {
return (
<E2ePadlock title={_t("The authenticity of this encrypted message can't be guaranteed on this device.")} icon="unauthenticated" {...props} />
<E2ePadlock
title={_t("The authenticity of this encrypted message can't be guaranteed on this device.")}
icon="unauthenticated"
{...props}
/>
);
}
class E2ePadlock extends React.Component {
static propTypes = {
icon: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};
interface IE2ePadlockProps {
icon: string;
title: string;
}
constructor() {
super();
interface IE2ePadlockState {
hover: boolean;
}
class E2ePadlock extends React.Component<IE2ePadlockProps, IE2ePadlockState> {
constructor(props) {
super(props);
this.state = {
hover: false,
@ -1202,14 +1254,13 @@ class E2ePadlock extends React.Component {
render() {
let tooltip = null;
if (this.state.hover) {
tooltip = <Tooltip className="mx_EventTile_e2eIcon_tooltip" label={this.props.title} dir="auto" />;
tooltip = <Tooltip className="mx_EventTile_e2eIcon_tooltip" label={this.props.title} />;
}
const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${this.props.icon}`;
return (
<div
className={classes}
onClick={this.onClick}
onMouseEnter={this.onHoverStart}
onMouseLeave={this.onHoverEnd}
>{tooltip}</div>
@ -1226,8 +1277,8 @@ interface ISentReceiptState {
}
class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptState> {
constructor() {
super();
constructor(props) {
super(props);
this.state = {
hover: false,
@ -1244,11 +1295,19 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
render() {
const isSent = !this.props.messageState || this.props.messageState === 'sent';
const isFailed = this.props.messageState === 'not_sent';
const receiptClasses = classNames({
'mx_EventTile_receiptSent': isSent,
'mx_EventTile_receiptSending': !isSent,
'mx_EventTile_receiptSending': !isSent && !isFailed,
});
let nonCssBadge = null;
if (isFailed) {
nonCssBadge = <NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
/>;
}
let tooltip = null;
if (this.state.hover) {
let label = _t("Sending your message...");
@ -1256,6 +1315,8 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
label = _t("Encrypting your message...");
} else if (isSent) {
label = _t("Your message was sent");
} else if (isFailed) {
label = _t("Failed to send");
}
// The yOffset is somewhat arbitrary - it just brings the tooltip down to be more associated
// with the read receipt.
@ -1264,6 +1325,7 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
return <span className="mx_EventTile_readAvatars">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
{nonCssBadge}
{tooltip}
</span>
</span>;

View file

@ -128,8 +128,8 @@ export default class LinkPreviewWidget extends React.Component {
let img;
if (image) {
img = <div className="mx_LinkPreviewWidget_image" style={{ height: thumbHeight }}>
<img style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }} src={image} onClick={this.onImageClick} />
</div>;
<img style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }} src={image} onClick={this.onImageClick} />
</div>;
}
// The description includes &-encoded HTML entities, we decode those as React treats the thing as an

View file

@ -30,6 +30,7 @@ import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@ -460,7 +461,7 @@ export default class MemberList extends React.Component {
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community");
} else if (room.isSpaceRoom()) {
} else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space");
}
@ -492,7 +493,7 @@ export default class MemberList extends React.Component {
let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader;
if (room?.isSpaceRoom()) {
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />

View file

@ -1,5 +1,5 @@
/*
Copyright 2015-2018, 2020, 2021 The Matrix.org Foundation C.I.C.
Copyright 2015-2021 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.
@ -13,15 +13,18 @@ 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, {createRef} from 'react';
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import dis from '../../../dispatcher/dispatcher';
import { ActionPayload } from "../../../dispatcher/payloads";
import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import ContentMessages from '../../../ContentMessages';
import E2EIcon from './E2EIcon';
import SettingsStore from "../../../settings/SettingsStore";
@ -29,24 +32,32 @@ import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview";
import {UIFeature} from "../../../settings/UIFeature";
import WidgetStore from "../../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
import {RecordingState} from "../../../voice/VoiceRecording";
import Tooltip, {Alignment} from "../elements/Tooltip";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from '../../../utils/ShieldUtils';
import SendMessageComposer from "./SendMessageComposer";
function ComposerAvatar(props) {
interface IComposerAvatarProps {
me: object;
}
function ComposerAvatar(props: IComposerAvatarProps) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
return <div className="mx_MessageComposer_avatar">
<MemberStatusMessageAvatar member={props.me} width={24} height={24} />
</div>;
}
ComposerAvatar.propTypes = {
me: PropTypes.object.isRequired,
};
interface ISendButtonProps {
onClick: () => void;
}
function SendButton(props) {
function SendButton(props: ISendButtonProps) {
return (
<AccessibleTooltipButton
className="mx_MessageComposer_sendMessage"
@ -56,10 +67,6 @@ function SendButton(props) {
);
}
SendButton.propTypes = {
onClick: PropTypes.func.isRequired,
};
const EmojiButton = ({addEmoji}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -67,7 +74,7 @@ const EmojiButton = ({addEmoji}) => {
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();
const EmojiPicker = sdk.getComponent('emojipicker.EmojiPicker');
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} catchTab={false}>
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>;
}
@ -97,39 +104,39 @@ const EmojiButton = ({addEmoji}) => {
</React.Fragment>;
};
class UploadButton extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
}
interface IUploadButtonProps {
roomId: string;
}
class UploadButton extends React.Component<IUploadButtonProps> {
private uploadInput = React.createRef<HTMLInputElement>();
private dispatcherRef: string;
constructor(props) {
super(props);
this.onUploadClick = this.onUploadClick.bind(this);
this.onUploadFileInputChange = this.onUploadFileInputChange.bind(this);
this._uploadInput = createRef();
this._dispatcherRef = dis.register(this.onAction);
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
dis.unregister(this.dispatcherRef);
}
onAction = payload => {
private onAction = (payload: ActionPayload) => {
if (payload.action === "upload_file") {
this.onUploadClick();
}
};
onUploadClick(ev) {
private onUploadClick = () => {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
this._uploadInput.current.click();
this.uploadInput.current.click();
}
onUploadFileInputChange(ev) {
private onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
if (ev.target.files.length === 0) return;
// take a copy so we can safely reset the value of the form control
@ -159,7 +166,7 @@ class UploadButton extends React.Component {
title={_t('Upload file')}
>
<input
ref={this._uploadInput}
ref={this.uploadInput}
type="file"
style={uploadInputStyle}
multiple
@ -170,29 +177,49 @@ class UploadButton extends React.Component {
}
}
interface IProps {
room: Room;
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
e2eStatus?: E2EStatus;
}
interface IState {
tombstone: MatrixEvent;
canSendMessages: boolean;
isComposerEmpty: boolean;
haveRecording: boolean;
recordingTimeLeftSeconds?: number;
me?: RoomMember;
}
@replaceableComponent("views.rooms.MessageComposer")
export default class MessageComposer extends React.Component {
export default class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef: string;
private messageComposerInput: SendMessageComposer;
private voiceRecordingButton: VoiceRecordComposerTile;
constructor(props) {
super(props);
this.onInputStateChanged = this.onInputStateChanged.bind(this);
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this);
this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.on('update', this._onActiveWidgetUpdate);
this._dispatcherRef = null;
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
this.state = {
tombstone: this._getRoomTombstone(),
tombstone: this.getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(),
hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room),
joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room),
isComposerEmpty: true,
haveRecording: false,
recordingTimeLeftSeconds: null, // when set to a number, shows a toast
};
}
onAction = (payload) => {
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
this.waitForOwnMember();
}
private onAction = (payload: ActionPayload) => {
if (payload.action === 'reply_to_event') {
// add a timeout for the reply preview to be rendered, so
// that the ScrollPanel listening to the resizeNotifier can
@ -204,21 +231,7 @@ 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() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
this._waitForOwnMember();
}
_waitForOwnMember() {
private waitForOwnMember() {
// if we have the member already, do that
const me = this.props.room.getMember(MatrixClientPeg.get().getUserId());
if (me) {
@ -236,35 +249,28 @@ export default class MessageComposer extends React.Component {
componentWillUnmount() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate);
VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate);
dis.unregister(this.dispatcherRef);
}
_onRoomStateEvents(ev, state) {
private onRoomStateEvents = (ev, state) => {
if (ev.getRoomId() !== this.props.room.roomId) return;
if (ev.getType() === 'm.room.tombstone') {
this.setState({tombstone: this._getRoomTombstone()});
this.setState({tombstone: this.getRoomTombstone()});
}
if (ev.getType() === 'm.room.power_levels') {
this.setState({canSendMessages: this.props.room.maySendMessage()});
}
}
_getRoomTombstone() {
private getRoomTombstone() {
return this.props.room.currentState.getStateEvents('m.room.tombstone', '');
}
onInputStateChanged(inputState) {
// Merge the new input state with old to support partial updates
inputState = Object.assign({}, this.state.inputState, inputState);
this.setState({inputState});
}
_onTombstoneClick(ev) {
private onTombstoneClick = (ev) => {
ev.preventDefault();
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
@ -294,7 +300,7 @@ export default class MessageComposer extends React.Component {
});
}
renderPlaceholderText() {
private renderPlaceholderText = () => {
if (this.props.replyToEvent) {
if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
@ -317,7 +323,15 @@ export default class MessageComposer extends React.Component {
});
}
sendMessage = () => {
sendMessage = async () => {
if (this.state.haveRecording && this.voiceRecordingButton) {
// There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording.
await this.voiceRecordingButton.send();
return;
}
// XXX: Private function access
this.messageComposerInput._sendMessage();
}
@ -327,8 +341,18 @@ export default class MessageComposer extends React.Component {
});
}
onVoiceUpdate = (haveRecording: boolean) => {
this.setState({haveRecording});
private onVoiceStoreUpdate = () => {
const recording = VoiceRecordingStore.instance.activeRecording;
this.setState({haveRecording: !!recording});
if (recording) {
// We show a little heads up that the recording is about to automatically end soon. The 3s
// display time is completely arbitrary. Note that we don't need to deregister the listener
// because the recording instance will clean that up for us.
recording.on(RecordingState.EndingSoon, ({secondsLeft}) => {
this.setState({recordingTimeLeftSeconds: secondsLeft});
setTimeout(() => this.setState({recordingTimeLeftSeconds: null}), 3000);
});
}
};
render() {
@ -352,7 +376,6 @@ export default class MessageComposer extends React.Component {
permalinkCreator={this.props.permalinkCreator}
replyToEvent={this.props.replyToEvent}
onChange={this.onChange}
// TODO: @@ TravisR - Disabling the composer doesn't work
disabled={this.state.haveRecording}
/>,
);
@ -373,8 +396,8 @@ export default class MessageComposer extends React.Component {
if (SettingsStore.getValue("feature_voice_messages")) {
controls.push(<VoiceRecordComposerTile
key="controls_voice_record"
room={this.props.room}
onRecording={this.onVoiceUpdate} />);
ref={c => this.voiceRecordingButton = c}
room={this.props.room} />);
}
if (!this.state.isComposerEmpty || this.state.haveRecording) {
@ -388,7 +411,7 @@ export default class MessageComposer extends React.Component {
const continuesLink = replacementRoomId ? (
<a href={makeRoomPermalink(replacementRoomId)}
className="mx_MessageComposer_roomReplaced_link"
onClick={this._onTombstoneClick}
onClick={this.onTombstoneClick}
>
{_t("The conversation continues here.")}
</a>
@ -396,7 +419,9 @@ export default class MessageComposer extends React.Component {
controls.push(<div className="mx_MessageComposer_replaced_wrapper" key="room_replaced">
<div className="mx_MessageComposer_replaced_valign">
<img className="mx_MessageComposer_roomReplaced_icon" src={require("../../../../res/img/room_replaced.svg")} />
<img className="mx_MessageComposer_roomReplaced_icon"
src={require("../../../../res/img/room_replaced.svg")}
/>
<span className="mx_MessageComposer_roomReplaced_header">
{_t("This room has been replaced and is no longer active.")}
</span><br />
@ -411,8 +436,18 @@ export default class MessageComposer extends React.Component {
);
}
let recordingTooltip;
const secondsLeft = Math.round(this.state.recordingTimeLeftSeconds);
if (secondsLeft) {
recordingTooltip = <Tooltip
label={_t("%(seconds)ss left", {seconds: secondsLeft})}
alignment={Alignment.Top} yOffset={-50}
/>;
}
return (
<div className="mx_MessageComposer mx_GroupLayout">
{recordingTooltip}
<div className="mx_MessageComposer_wrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
<div className="mx_MessageComposer_row">
@ -423,14 +458,3 @@ export default class MessageComposer extends React.Component {
);
}
}
MessageComposer.propTypes = {
// js-sdk Room object
room: PropTypes.object.isRequired,
// string representing the current voip call state
callState: PropTypes.string,
// string representing the current room app drawer state
showApps: PropTypes.bool,
};

View file

@ -30,7 +30,7 @@ interface IProps {
* If true, the badge will show a count if at all possible. This is typically
* used to override the user's preference for things like room sublists.
*/
forceCount: boolean;
forceCount?: boolean;
/**
* The room ID, if any, the badge represents.

View file

@ -53,9 +53,9 @@ export default class PinnedEventTile extends React.Component {
if (index !== -1) {
pinned.splice(index, 1);
MatrixClientPeg.get().sendStateEvent(this.props.mxRoom.roomId, 'm.room.pinned_events', {pinned}, '')
.then(() => {
if (this.props.onUnpinned) this.props.onUnpinned();
});
.then(() => {
if (this.props.onUnpinned) this.props.onUnpinned();
});
} else if (this.props.onUnpinned) this.props.onUnpinned();
}
};
@ -98,8 +98,11 @@ export default class PinnedEventTile extends React.Component {
{ formatFullDate(new Date(this.props.mxEvent.getTs())) }
</span>
<div className="mx_PinnedEventTile_message">
<MessageEvent mxEvent={this.props.mxEvent} className="mx_PinnedEventTile_body" maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
<MessageEvent
mxEvent={this.props.mxEvent}
className="mx_PinnedEventTile_body"
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
/>
</div>
</div>

View file

@ -64,10 +64,10 @@ export default class PinnedEventsPanel extends React.Component {
pinnedEvents.getContent().pinned.map((eventId) => {
promises.push(cli.getEventTimeline(this.props.room.getUnfilteredTimelineSet(), eventId, 0).then(
(timeline) => {
const event = timeline.getEvents().find((e) => e.getId() === eventId);
return {eventId, timeline, event};
}).catch((err) => {
(timeline) => {
const event = timeline.getEvents().find((e) => e.getId() === eventId);
return {eventId, timeline, event};
}).catch((err) => {
console.error("Error looking up pinned event " + eventId + " in room " + this.props.room.roomId);
console.error(err);
return null; // return lack of context to avoid unhandled errors
@ -113,10 +113,14 @@ export default class PinnedEventsPanel extends React.Component {
}
return this.state.pinned.map((context) => {
return (<PinnedEventTile key={context.event.getId()}
mxRoom={this.props.room}
mxEvent={context.event}
onUnpinned={this._updatePinnedMessages} />);
return (
<PinnedEventTile
key={context.event.getId()}
mxRoom={this.props.room}
mxEvent={context.event}
onUnpinned={this._updatePinnedMessages}
/>
);
});
}

View file

@ -17,22 +17,13 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import '../../../VelocityBounce';
import { _t } from '../../../languageHandler';
import {formatDate} from '../../../DateUtils';
import Velociraptor from "../../../Velociraptor";
import NodeAnimator from "../../../NodeAnimator";
import * as sdk from "../../../index";
import {toPx} from "../../../utils/units";
import {replaceableComponent} from "../../../utils/replaceableComponent";
let bounce = false;
try {
if (global.localStorage) {
bounce = global.localStorage.getItem('avatar_bounce') == 'true';
}
} catch (e) {
}
@replaceableComponent("views.rooms.ReadReceiptMarker")
export default class ReadReceiptMarker extends React.PureComponent {
static propTypes = {
@ -115,7 +106,18 @@ export default class ReadReceiptMarker extends React.PureComponent {
// we've already done our display - nothing more to do.
return;
}
this._animateMarker();
}
componentDidUpdate(prevProps) {
const differentLeftOffset = prevProps.leftOffset !== this.props.leftOffset;
const visibilityChanged = prevProps.hidden !== this.props.hidden;
if (differentLeftOffset || visibilityChanged) {
this._animateMarker();
}
}
_animateMarker() {
// treat new RRs as though they were off the top of the screen
let oldTop = -15;
@ -139,42 +141,18 @@ export default class ReadReceiptMarker extends React.PureComponent {
}
const startStyles = [];
const enterTransitionOpts = [];
if (oldInfo && oldInfo.left) {
// start at the old height and in the old h pos
startStyles.push({ top: startTopOffset+"px",
left: toPx(oldInfo.left) });
const reorderTransitionOpts = {
duration: 100,
easing: 'easeOut',
};
enterTransitionOpts.push(reorderTransitionOpts);
}
// then shift to the rightmost column,
// and then it will drop down to its resting position
//
// XXX: We use a small left value to trick velocity-animate into actually animating.
// This is a very annoying bug where if it thinks there's no change to `left` then it'll
// skip applying it, thus making our read receipt at +14px instead of +0px like it
// should be. This does cause a tiny amount of drift for read receipts, however with a
// value so small it's not perceived by a user.
// Note: Any smaller values (or trying to interchange units) might cause read receipts to
// fail to fall down or cause gaps.
startStyles.push({ top: startTopOffset+'px', left: '1px' });
enterTransitionOpts.push({
duration: bounce ? Math.min(Math.log(Math.abs(startTopOffset)) * 200, 3000) : 300,
easing: bounce ? 'easeOutBounce' : 'easeOutCubic',
});
startStyles.push({ top: startTopOffset+'px', left: '0' });
this.setState({
suppressDisplay: false,
startStyles: startStyles,
enterTransitionOpts: enterTransitionOpts,
});
}
@ -187,7 +165,6 @@ export default class ReadReceiptMarker extends React.PureComponent {
const style = {
left: toPx(this.props.leftOffset),
top: '0px',
visibility: this.props.hidden ? 'hidden' : 'visible',
};
let title;
@ -210,9 +187,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
}
return (
<Velociraptor
startStyles={this.state.startStyles}
enterTransitionOpts={this.state.enterTransitionOpts} >
<NodeAnimator startStyles={this.state.startStyles}>
<MemberAvatar
member={this.props.member}
fallbackUserId={this.props.fallbackUserId}
@ -223,7 +198,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
onClick={this.props.onClick}
inputRef={this._avatar}
/>
</Velociraptor>
</NodeAnimator>
);
}
}

View file

@ -79,8 +79,13 @@ export default class ReplyPreview extends React.Component {
{ _t('Replying') }
</div>
<div className="mx_ReplyPreview_header mx_ReplyPreview_cancel">
<img className="mx_filterFlipColor" src={require("../../../../res/img/cancel.svg")} width="18" height="18"
onClick={cancelQuoting} />
<img
className="mx_filterFlipColor"
src={require("../../../../res/img/cancel.svg")}
width="18"
height="18"
onClick={cancelQuoting}
/>
</div>
<div className="mx_ReplyPreview_clear" />
<EventTile

View file

@ -88,11 +88,11 @@ export default class RoomDetailRow extends React.Component {
const name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
const guestRead = room.worldReadable ? (
<div className="mx_RoomDirectory_perm">{ _t('World readable') }</div>
) : <div />;
<div className="mx_RoomDirectory_perm">{ _t('World readable') }</div>
) : <div />;
const guestJoin = room.guestCanJoin ? (
<div className="mx_RoomDirectory_perm">{ _t('Guests can join') }</div>
) : <div />;
<div className="mx_RoomDirectory_perm">{ _t('Guests can join') }</div>
) : <div />;
const perms = (guestRead || guestJoin) ? (<div className="mx_RoomDirectory_perms">
{ guestRead }&nbsp;

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 Vector Creations Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2015-2018, 2020, 2021 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.
@ -37,7 +35,6 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import GroupAvatar from "../avatars/GroupAvatar";
import ExtraTile from "./ExtraTile";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
@ -289,12 +286,11 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
// 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);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
}
public componentDidMount(): void {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
SpaceStore.instance.on(SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
this.customTagStoreRef = CustomRoomTagStore.addListener(this.updateLists);
@ -493,7 +489,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
isSelected={false}
displayName={g.name}
avatar={avatar}
notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
notificationState={StaticNotificationState.RED_EXCLAMATION}
onClick={openGroup}
key={`temporaryGroupTile_${g.groupId}`}
/>
@ -502,62 +498,57 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
}
private renderSublists(): React.ReactElement[] {
const components: React.ReactElement[] = [];
const tagOrder = TAG_ORDER.reduce((p, c) => {
if (c === CUSTOM_TAGS_BEFORE_TAG) {
const customTags = Object.keys(this.state.sublists)
.filter(t => isCustomTag(t));
p.push(...customTags);
}
p.push(c);
return p;
}, [] as TagID[]);
// show a skeleton UI if the user is in no rooms and they are not filtering
const showSkeleton = !this.state.isNameFiltering &&
Object.values(RoomListStore.instance.unfilteredLists).every(list => !list?.length);
for (const orderedTagId of tagOrder) {
const orderedRooms = this.state.sublists[orderedTagId] || [];
let extraTiles = null;
if (orderedTagId === DefaultTagID.Invite) {
extraTiles = this.renderCommunityInvites();
} else if (orderedTagId === DefaultTagID.Suggested) {
extraTiles = this.renderSuggestedRooms();
return TAG_ORDER.reduce((tags, tagId) => {
if (tagId === CUSTOM_TAGS_BEFORE_TAG) {
const customTags = Object.keys(this.state.sublists)
.filter(tagId => isCustomTag(tagId));
tags.push(...customTags);
}
tags.push(tagId);
return tags;
}, [] as TagID[])
.map(orderedTagId => {
let extraTiles = null;
if (orderedTagId === DefaultTagID.Invite) {
extraTiles = this.renderCommunityInvites();
} else if (orderedTagId === DefaultTagID.Suggested) {
extraTiles = this.renderSuggestedRooms();
}
const totalTiles = orderedRooms.length + (extraTiles ? extraTiles.length : 0);
if (totalTiles === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
continue; // skip tag - not needed
}
const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
? customTagAesthetics(orderedTagId)
: this.tagAesthetics[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
? customTagAesthetics(orderedTagId)
: this.tagAesthetics[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
components.push(<RoomSublist
key={`sublist-${orderedTagId}`}
tagId={orderedTagId}
forRooms={true}
startAsHidden={aesthetics.defaultHidden}
label={aesthetics.sectionLabelRaw ? aesthetics.sectionLabelRaw : _t(aesthetics.sectionLabel)}
onAddRoom={aesthetics.onAddRoom}
addRoomLabel={aesthetics.addRoomLabel ? _t(aesthetics.addRoomLabel) : aesthetics.addRoomLabel}
addRoomContextMenu={aesthetics.addRoomContextMenu}
isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
showSkeleton={showSkeleton}
extraTiles={extraTiles}
/>);
}
return components;
// The cost of mounting/unmounting this component offsets the cost
// of keeping it in the DOM and hiding it when it is not required
return <RoomSublist
key={`sublist-${orderedTagId}`}
tagId={orderedTagId}
forRooms={true}
startAsHidden={aesthetics.defaultHidden}
label={aesthetics.sectionLabelRaw ? aesthetics.sectionLabelRaw : _t(aesthetics.sectionLabel)}
onAddRoom={aesthetics.onAddRoom}
addRoomLabel={aesthetics.addRoomLabel ? _t(aesthetics.addRoomLabel) : aesthetics.addRoomLabel}
addRoomContextMenu={aesthetics.addRoomContextMenu}
isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
showSkeleton={showSkeleton}
extraTiles={extraTiles}
resizeNotifier={this.props.resizeNotifier}
alwaysVisible={ALWAYS_VISIBLE_TAGS.includes(orderedTagId)}
/>
});
}
public render() {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
let explorePrompt: JSX.Element;
if (!this.props.isMinimized) {
if (this.state.isNameFiltering) {
@ -578,21 +569,23 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
{ this.props.activeSpace ? _t("Explore rooms") : _t("Explore all public rooms") }
</AccessibleButton>
</div>;
} else if (this.props.activeSpace) {
} else if (
this.props.activeSpace?.canInvite(userId) || this.props.activeSpace?.getMyMembership() === "join"
) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{ _t("Quick actions") }</div>
{ this.props.activeSpace.canInvite(MatrixClientPeg.get().getUserId()) && <AccessibleButton
{ this.props.activeSpace.canInvite(userId) && <AccessibleButton
className="mx_RoomList_explorePrompt_spaceInvite"
onClick={this.onSpaceInviteClick}
>
{_t("Invite people")}
</AccessibleButton> }
<AccessibleButton
{ this.props.activeSpace.getMyMembership() === "join" && <AccessibleButton
className="mx_RoomList_explorePrompt_spaceExplore"
onClick={this.onExplore}
>
{_t("Explore rooms")}
</AccessibleButton>
</AccessibleButton> }
</div>;
} else if (Object.values(this.state.sublists).some(list => list.length > 0)) {
const unfilteredLists = RoomListStore.instance.unfilteredLists

View file

@ -19,6 +19,7 @@ import React, {useState} from "react";
import { _t } from "../../../languageHandler";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import SpaceStore from "../../../stores/SpaceStore";
const RoomListNumResults: React.FC = () => {
const [count, setCount] = useState<number>(null);
@ -34,7 +35,10 @@ const RoomListNumResults: React.FC = () => {
if (typeof count !== "number") return null;
return <div className="mx_LeftPanel_roomListFilterCount">
{_t("%(count)s results", { count })}
{ SpaceStore.instance.spacePanelSpaces.length
? _t("%(count)s results in all spaces", { count })
: _t("%(count)s results", { count })
}
</div>;
};

View file

@ -44,6 +44,7 @@ import { ActionPayload } from "../../../dispatcher/payloads";
import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import { polyfillTouchEvent } from "../../../@types/polyfill";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays";
@ -74,7 +75,8 @@ interface IProps {
tagId: TagID;
onResize: () => void;
showSkeleton?: boolean;
alwaysVisible?: boolean;
resizeNotifier: ResizeNotifier;
extraTiles?: ReactComponentElement<typeof ExtraTile>[];
// TODO: Account for https://github.com/vector-im/element-web/issues/14179
@ -125,8 +127,6 @@ export default class RoomSublist extends React.Component<IProps, IState> {
};
// Why Object.assign() and not this.state.height? Because TypeScript says no.
this.state = Object.assign(this.state, {height: this.calculateInitialHeight()});
this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
}
private calculateInitialHeight() {
@ -242,6 +242,11 @@ export default class RoomSublist extends React.Component<IProps, IState> {
return false;
}
public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
}
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated);
@ -524,6 +529,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
tiles.push(<RoomTile
room={room}
key={`room-${room.roomId}`}
resizeNotifier={this.props.resizeNotifier}
showMessagePreview={this.layout.showPreviews}
isMinimized={this.props.isMinimized}
tag={this.props.tagId}
@ -759,6 +765,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
'mx_RoomSublist': true,
'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition,
'mx_RoomSublist_minimized': this.props.isMinimized,
'mx_RoomSublist_hidden': (
!this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true
),
});
let content = null;

View file

@ -1,8 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015-2017, 2019-2021 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.
@ -19,6 +17,7 @@ limitations under the License.
import React, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
@ -51,13 +50,17 @@ import IconizedContextMenu, {
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getUnsentMessages } from "../../structures/RoomStatusBar";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
interface IProps {
room: Room;
showMessagePreview: boolean;
isMinimized: boolean;
tag: TagID;
resizeNotifier: ResizeNotifier;
}
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
@ -67,6 +70,7 @@ interface IState {
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
messagePreview?: string;
hasUnsentEvents: boolean;
}
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
@ -93,26 +97,20 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
hasUnsentEvents: this.countUnsentEvents() > 0,
// generatePreview() will return nothing if the user has previews disabled
messagePreview: this.generatePreview(),
};
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps = EchoChamber.forRoom(this.props.room);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
CommunityPrototypeStore.instance.on(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
this.props.room.on("Room.name", this.onRoomNameUpdate);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
}
private countUnsentEvents(): number {
return getUnsentMessages(this.props.room).length;
}
private onRoomNameUpdate = (room) => {
@ -123,6 +121,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.forceUpdate(); // notification state changed - update
};
private onResize = () => {
if (this.showMessagePreview && !this.state.messagePreview) {
this.setState({messagePreview: this.generatePreview()});
}
};
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (!room?.roomId === this.props.room.roomId) return;
this.setState({hasUnsentEvents: this.countUnsentEvents() > 0});
};
private onRoomPropertyUpdate = (property: CachedRoomKey) => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile
@ -167,6 +176,21 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
if (this.state.selected) {
this.scrollIntoView();
}
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.roomProps.on("Room.name", this.onRoomNameUpdate);
CommunityPrototypeStore.instance.on(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
}
public componentWillUnmount() {
@ -182,8 +206,19 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
this.props.room.off("Room.name", this.onRoomNameUpdate);
}
if (this.props.resizeNotifier) {
this.props.resizeNotifier.off("middlePanelResized", this.onResize);
}
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.roomProps.off("Room.name", this.onRoomNameUpdate);
CommunityPrototypeStore.instance.off(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
}
private onAction = (payload: ActionPayload) => {
@ -549,21 +584,38 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
let badge: React.ReactNode;
if (!this.props.isMinimized) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
if (this.state.hasUnsentEvents) {
// hardcode the badge to a danger state when there's unsent messages
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
} else if (this.notificationState) {
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
}
}
let messagePreview = null;
if (this.showMessagePreview && this.state.messagePreview) {
messagePreview = (
<div className="mx_RoomTile_messagePreview" id={messagePreviewId(this.props.room.roomId)}>
<div
className="mx_RoomTile_messagePreview"
id={messagePreviewId(this.props.room.roomId)}
title={this.state.messagePreview}
>
{this.state.messagePreview}
</div>
);

View file

@ -477,6 +477,10 @@ export default class SendMessageComposer extends React.Component {
}
onAction = (payload) => {
// don't let the user into the composer if it is disabled - all of these branches lead
// to the cursor being in the composer
if (this.props.disabled) return;
switch (payload.action) {
case 'reply_to_event':
case Action.FocusComposer:
@ -502,9 +506,8 @@ export default class SendMessageComposer extends React.Component {
member.rawDisplayName : userId;
const caret = this._editorRef.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
// index is -1 if there are no parts but we only care for if this would be the part in position 0
const insertIndex = position.index > 0 ? position.index : 0;
const parts = partCreator.createMentionParts(insertIndex, displayName, userId);
// Insert suffix only if the caret is at the start of the composer
const parts = partCreator.createMentionParts(caret.offset === 0, displayName, userId);
model.transform(() => {
const addedLen = model.insert(parts, position);
return model.positionForOffset(caret.offset + addedLen, true);

View file

@ -266,25 +266,25 @@ export default class Stickerpicker extends React.Component {
width: this.popoverWidth,
}}
>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} zIndex={STICKERPICKER_Z_INDEX}>
<AppTile
app={stickerApp}
room={this.props.room}
fullWidth={true}
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
showTitle={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
userWidget={true}
/>
</PersistedElement>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} zIndex={STICKERPICKER_Z_INDEX}>
<AppTile
app={stickerApp}
room={this.props.room}
fullWidth={true}
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
showTitle={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
userWidget={true}
/>
</PersistedElement>
</div>
</div>
);

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd.
Copyright 2019-2021 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.
@ -15,9 +15,9 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {_t} from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import * as sdk from "../../../index";
@ -26,12 +26,24 @@ import {isValid3pidInvite} from "../../../RoomInvite";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
interface IProps {
event: MatrixEvent;
}
interface IState {
stateKey: string;
roomId: string;
displayName: string;
invited: boolean;
canKick: boolean;
senderName: string;
}
@replaceableComponent("views.rooms.ThirdPartyMemberInfo")
export default class ThirdPartyMemberInfo extends React.Component {
static propTypes = {
event: PropTypes.instanceOf(MatrixEvent).isRequired,
};
export default class ThirdPartyMemberInfo extends React.Component<IProps, IState> {
private room: Room;
constructor(props) {
super(props);
@ -124,7 +136,7 @@ export default class ThirdPartyMemberInfo extends React.Component {
}
let scopeHeader;
if (this.room.isSpaceRoom()) {
if (SettingsStore.getValue("feature_spaces") && this.room.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={this.room} height={32} width={32} />
<RoomName room={this.room} />

View file

@ -16,22 +16,29 @@ limitations under the License.
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {_t} from "../../../languageHandler";
import React from "react";
import {VoiceRecorder} from "../../../voice/VoiceRecorder";
import React, {ReactNode} from "react";
import {RecordingState, VoiceRecording} from "../../../voice/VoiceRecording";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import classNames from "classnames";
import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import RecordingPlayback from "../voice_messages/RecordingPlayback";
import {MsgType} from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import CallMediaHandler from "../../../CallMediaHandler";
interface IProps {
room: Room;
onRecording: (haveRecording: boolean) => void;
}
interface IState {
recorder?: VoiceRecorder;
recorder?: VoiceRecording;
recordingPhase?: RecordingState;
}
/**
@ -43,59 +50,181 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
super(props);
this.state = {
recorder: null, // not recording by default
recorder: null, // no recording started by default
};
}
private onStartStopVoiceMessage = async () => {
// TODO: @@ TravisR: We do not want to auto-send on stop.
if (this.state.recorder) {
await this.state.recorder.stop();
const mxc = await this.state.recorder.upload();
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
body: "Voice message",
msgtype: "org.matrix.msc2516.voice",
url: mxc,
});
this.setState({recorder: null});
this.props.onRecording(false);
return;
public async componentWillUnmount() {
await VoiceRecordingStore.instance.disposeRecording();
}
// called by composer
public async send() {
if (!this.state.recorder) {
throw new Error("No recording started - cannot send anything");
}
const recorder = new VoiceRecorder(MatrixClientPeg.get());
await recorder.start();
this.props.onRecording(true);
this.setState({recorder});
await this.state.recorder.stop();
const mxc = await this.state.recorder.upload();
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
"body": "Voice message",
//"msgtype": "org.matrix.msc2516.voice",
"msgtype": MsgType.Audio,
"url": mxc,
"info": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
mimetype: this.state.recorder.contentType,
size: this.state.recorder.contentLength,
},
// MSC1767 experiment
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc1767.file": {
url: mxc,
name: "Voice message.ogg",
mimetype: this.state.recorder.contentType,
size: this.state.recorder.contentLength,
},
"org.matrix.msc1767.audio": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
// Events can't have floats, so we try to maintain resolution by using 1024
// as a maximum value. The waveform contains values between zero and 1, so this
// should come out largely sane.
//
// We're expecting about one data point per second of audio.
waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)),
},
"org.matrix.msc2516.voice": {}, // No content, this is a rendering hint
});
await this.disposeRecording();
}
private async disposeRecording() {
await VoiceRecordingStore.instance.disposeRecording();
// Reset back to no recording, which means no phase (ie: restart component entirely)
this.setState({recorder: null, recordingPhase: null});
}
private onCancel = async () => {
await this.disposeRecording();
};
private renderWaveformArea() {
if (!this.state.recorder) return null;
private onRecordStartEndClick = async () => {
if (this.state.recorder) {
await this.state.recorder.stop();
return;
}
return <div className='mx_VoiceRecordComposerTile_waveformContainer'>
// The "microphone access error" dialogs are used a lot, so let's functionify them
const accessError = () => {
Modal.createTrackedDialog('Microphone Access Error', '', ErrorDialog, {
title: _t("Unable to access your microphone"),
description: <>
<p>{_t(
"We were unable to access your microphone. Please check your browser settings and try again.",
)}</p>
</>,
});
};
// Do a sanity test to ensure we're about to grab a valid microphone reference. Things might
// change between this and recording, but at least we will have tried.
try {
const devices = await CallMediaHandler.getDevices();
if (!devices?.['audioinput']?.length) {
Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
title: _t("No microphone found"),
description: <>
<p>{_t(
"We didn't find a microphone on your device. Please check your settings and try again.",
)}</p>
</>,
});
return;
}
// else we probably have a device that is good enough
} catch (e) {
console.error("Error getting devices: ", e);
accessError();
return;
}
try {
const recorder = VoiceRecordingStore.instance.startRecording();
await recorder.start();
// We don't need to remove the listener: the recorder will clean that up for us.
recorder.on(UPDATE_EVENT, (ev: RecordingState) => {
if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here
this.setState({recordingPhase: ev});
});
this.setState({recorder, recordingPhase: RecordingState.Started});
} catch (e) {
console.error("Error starting recording: ", e);
accessError();
// noinspection ES6MissingAwait - if this goes wrong we don't want it to affect the call stack
VoiceRecordingStore.instance.disposeRecording();
}
};
private renderWaveformArea(): ReactNode {
if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
if (this.state.recordingPhase !== RecordingState.Started) {
// TODO: @@ TR: Should we disable this during upload? What does a failed upload look like?
return <RecordingPlayback playback={this.state.recorder.getPlayback()} />;
}
// only other UI is the recording-in-progress UI
return <div className="mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording">
<LiveRecordingClock recorder={this.state.recorder} />
<LiveRecordingWaveform recorder={this.state.recorder} />
</div>;
}
public render() {
const classes = classNames({
'mx_MessageComposer_button': !this.state.recorder,
'mx_MessageComposer_voiceMessage': !this.state.recorder,
'mx_VoiceRecordComposerTile_stop': !!this.state.recorder,
});
public render(): ReactNode {
let recordingInfo;
let deleteButton;
if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
const classes = classNames({
'mx_MessageComposer_button': !this.state.recorder,
'mx_MessageComposer_voiceMessage': !this.state.recorder,
'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
});
let tooltip = _t("Record a voice message");
if (!!this.state.recorder) {
// TODO: @@ TravisR: Change to match behaviour
tooltip = _t("Stop & send recording");
let tooltip = _t("Record a voice message");
if (!!this.state.recorder) {
tooltip = _t("Stop the recording");
}
let stopOrRecordBtn = <AccessibleTooltipButton
className={classes}
onClick={this.onRecordStartEndClick}
title={tooltip}
/>;
if (this.state.recorder && !this.state.recorder?.isRecording) {
stopOrRecordBtn = null;
}
recordingInfo = stopOrRecordBtn;
}
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
deleteButton = <AccessibleTooltipButton
className='mx_VoiceRecordComposerTile_delete'
title={_t("Delete recording")}
onClick={this.onCancel}
/>;
}
return (<>
{deleteButton}
{this.renderWaveformArea()}
<AccessibleTooltipButton
className={classes}
onClick={this.onStartStopVoiceMessage}
title={tooltip}
/>
{recordingInfo}
</>);
}
}

View file

@ -154,7 +154,7 @@ export default class ChangeAvatar extends React.Component {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
// XXX: FIXME: once we track in the JS what our own displayname is(!) then use it here rather than ?
avatarImg = <BaseAvatar width={this.props.width} height={this.props.height} resizeMethod='crop'
name='?' idName={MatrixClientPeg.get().getUserIdLocalpart()} url={this.state.avatarUrl} />;
name='?' idName={MatrixClientPeg.get().getUserIdLocalpart()} url={this.state.avatarUrl} />;
}
let uploadSection;

View file

@ -206,7 +206,7 @@ export default class ChangePassword extends React.Component {
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Passwords can't be empty"),
},
],
],
});
onChangeNewPassword = (ev) => {
@ -245,7 +245,7 @@ export default class ChangePassword extends React.Component {
},
invalid: () => _t("Passwords don't match"),
},
],
],
});
onClickChange = async (ev) => {

View file

@ -259,7 +259,7 @@ export default class CrossSigningPanel extends React.PureComponent {
<td>{_t("Homeserver feature support:")}</td>
<td>{homeserverSupportsCrossSigning ? _t("exists") : _t("not found")}</td>
</tr>
</tbody></table>
</tbody></table>
</details>
{errorSection}
{actionRow}

View file

@ -214,7 +214,7 @@ export default class DevicesPanel extends React.Component {
const deleteButton = this.state.deleting ?
<Spinner w={22} h={22} /> :
<AccessibleButton onClick={this._onDeleteClick} kind="danger_sm">
{ _t("Delete %(count)s sessions", {count: this.state.selectedDevices.length}) }
{ _t("Delete %(count)s sessions", {count: this.state.selectedDevices.length})}
</AccessibleButton>;
const classes = classNames(this.props.className, "mx_DevicesPanel");

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2021 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.
@ -28,10 +28,17 @@ import {SettingLevel} from "../../../settings/SettingLevel";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SeshatResetDialog from '../dialogs/SeshatResetDialog';
interface IState {
enabling: boolean;
eventIndexSize: number;
roomCount: number;
eventIndexingEnabled: boolean;
}
@replaceableComponent("views.settings.EventIndexPanel")
export default class EventIndexPanel extends React.Component {
constructor() {
super();
export default class EventIndexPanel extends React.Component<{}, IState> {
constructor(props) {
super(props);
this.state = {
enabling: false,
@ -68,7 +75,7 @@ export default class EventIndexPanel extends React.Component {
}
}
async componentDidMount(): void {
componentDidMount(): void {
this.updateState();
}
@ -102,8 +109,10 @@ export default class EventIndexPanel extends React.Component {
});
}
_onManage = async () => {
private onManage = async () => {
Modal.createTrackedDialogAsync('Message search', 'Message search',
// @ts-ignore: TS doesn't seem to like the type of this now that it
// has also been converted to TS as well, but I can't figure out why...
import('../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog'),
{
onFinished: () => {},
@ -111,7 +120,7 @@ export default class EventIndexPanel extends React.Component {
);
}
_onEnable = async () => {
private onEnable = async () => {
this.setState({
enabling: true,
});
@ -123,14 +132,13 @@ export default class EventIndexPanel extends React.Component {
await this.updateState();
}
_confirmEventStoreReset = () => {
const self = this;
private confirmEventStoreReset = () => {
const { close } = Modal.createDialog(SeshatResetDialog, {
onFinished: async (success) => {
if (success) {
await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false);
await EventIndexPeg.deleteEventIndex();
await self._onEnable();
await this.onEnable();
close();
}
},
@ -145,20 +153,19 @@ export default class EventIndexPanel extends React.Component {
if (EventIndexPeg.get() !== null) {
eventIndexingSettings = (
<div>
<div className='mx_SettingsTab_subsectionText'>
{_t("Securely cache encrypted messages locally for them " +
"to appear in search results, using %(size)s to store messages from %(rooms)s rooms.",
{
size: formatBytes(this.state.eventIndexSize, 0),
// This drives the singular / plural string
// selection for "room" / "rooms" only.
count: this.state.roomCount,
rooms: formatCountLong(this.state.roomCount),
},
)}
</div>
<div className='mx_SettingsTab_subsectionText'>{_t(
"Securely cache encrypted messages locally for them " +
"to appear in search results, using %(size)s to store messages from %(rooms)s rooms.",
{
size: formatBytes(this.state.eventIndexSize, 0),
// This drives the singular / plural string
// selection for "room" / "rooms" only.
count: this.state.roomCount,
rooms: formatCountLong(this.state.roomCount),
},
)}</div>
<div>
<AccessibleButton kind="primary" onClick={this._onManage}>
<AccessibleButton kind="primary" onClick={this.onManage}>
{_t("Manage")}
</AccessibleButton>
</div>
@ -167,13 +174,13 @@ export default class EventIndexPanel extends React.Component {
} else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) {
eventIndexingSettings = (
<div>
<div className='mx_SettingsTab_subsectionText'>
{_t( "Securely cache encrypted messages locally for them to " +
"appear in search results.")}
</div>
<div className='mx_SettingsTab_subsectionText'>{_t(
"Securely cache encrypted messages locally for them to " +
"appear in search results.",
)}</div>
<div>
<AccessibleButton kind="primary" disabled={this.state.enabling}
onClick={this._onEnable}>
onClick={this.onEnable}>
{_t("Enable")}
</AccessibleButton>
{this.state.enabling ? <InlineSpinner /> : <div />}
@ -188,40 +195,36 @@ export default class EventIndexPanel extends React.Component {
);
eventIndexingSettings = (
<div className='mx_SettingsTab_subsectionText'>
<div className='mx_SettingsTab_subsectionText'>{_t(
"%(brand)s is missing some components required for securely " +
"caching encrypted messages locally. If you'd like to " +
"experiment with this feature, build a custom %(brand)s Desktop " +
"with <nativeLink>search components added</nativeLink>.",
{
_t( "%(brand)s is missing some components required for securely " +
"caching encrypted messages locally. If you'd like to " +
"experiment with this feature, build a custom %(brand)s Desktop " +
"with <nativeLink>search components added</nativeLink>.",
{
brand,
},
{
'nativeLink': (sub) => <a href={nativeLink} target="_blank"
rel="noreferrer noopener">{sub}</a>,
},
)
}
</div>
brand,
},
{
nativeLink: sub => <a href={nativeLink}
target="_blank" rel="noreferrer noopener"
>{sub}</a>,
},
)}</div>
);
} else if (!EventIndexPeg.platformHasSupport()) {
eventIndexingSettings = (
<div className='mx_SettingsTab_subsectionText'>
<div className='mx_SettingsTab_subsectionText'>{_t(
"%(brand)s can't securely cache encrypted messages locally " +
"while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> " +
"for encrypted messages to appear in search results.",
{
_t( "%(brand)s can't securely cache encrypted messages locally " +
"while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> " +
"for encrypted messages to appear in search results.",
{
brand,
},
{
'desktopLink': (sub) => <a href="https://element.io/get-started"
target="_blank" rel="noreferrer noopener">{sub}</a>,
},
)
}
</div>
brand,
},
{
desktopLink: sub => <a href="https://element.io/get-started"
target="_blank" rel="noreferrer noopener"
>{sub}</a>,
},
)}</div>
);
} else {
eventIndexingSettings = (
@ -233,19 +236,18 @@ export default class EventIndexPanel extends React.Component {
}
</p>
{EventIndexPeg.error && (
<details>
<summary>{_t("Advanced")}</summary>
<code>
{EventIndexPeg.error.message}
</code>
<p>
<AccessibleButton key="delete" kind="danger" onClick={this._confirmEventStoreReset}>
{_t("Reset")}
</AccessibleButton>
</p>
</details>
<details>
<summary>{_t("Advanced")}</summary>
<code>
{EventIndexPeg.error.message}
</code>
<p>
<AccessibleButton key="delete" kind="danger" onClick={this.confirmEventStoreReset}>
{_t("Reset")}
</AccessibleButton>
</p>
</details>
)}
</div>
);
}

View file

@ -100,7 +100,7 @@ export default class Notifications extends React.Component {
MatrixClientPeg.get().setPushRuleEnabled(
'global', self.state.masterPushRule.kind, self.state.masterPushRule.rule_id, !checked,
).then(function() {
self._refreshFromServer();
self._refreshFromServer();
});
};
@ -580,12 +580,12 @@ export default class Notifications extends React.Component {
"vectorRuleId": "_keywords",
"description": (
<span>
{ _t('Messages containing <span>keywords</span>',
{},
{ 'span': (sub) =>
<span className="mx_UserNotifSettings_keywords" onClick={ self.onKeywordsClicked }>{sub}</span>,
},
)}
{ _t('Messages containing <span>keywords</span>',
{},
{ 'span': (sub) =>
<span className="mx_UserNotifSettings_keywords" onClick={ self.onKeywordsClicked }>{sub}</span>,
},
)}
</span>
),
"vectorState": self.state.vectorContentRules.vectorState,
@ -743,8 +743,8 @@ export default class Notifications extends React.Component {
emailNotificationsRow(address, label) {
return <LabelledToggleSwitch value={this.hasEmailPusher(this.state.pushers, address)}
onChange={this.onEnableEmailNotificationsChange.bind(this, address)}
label={label} key={`emailNotif_${label}`} />;
onChange={this.onEnableEmailNotificationsChange.bind(this, address)}
label={label} key={`emailNotif_${label}`} />;
}
render() {
@ -757,8 +757,8 @@ export default class Notifications extends React.Component {
let masterPushRuleDiv;
if (this.state.masterPushRule) {
masterPushRuleDiv = <LabelledToggleSwitch value={!this.state.masterPushRule.enabled}
onChange={this.onEnableNotificationsChange}
label={_t('Enable notifications for this account')} />;
onChange={this.onEnableNotificationsChange}
label={_t('Enable notifications for this account')} />;
}
let clearNotificationsButton;
@ -874,16 +874,16 @@ export default class Notifications extends React.Component {
{ spinner }
<LabelledToggleSwitch value={SettingsStore.getValue("notificationsEnabled")}
onChange={this.onEnableDesktopNotificationsChange}
label={_t('Enable desktop notifications for this session')} />
onChange={this.onEnableDesktopNotificationsChange}
label={_t('Enable desktop notifications for this session')} />
<LabelledToggleSwitch value={SettingsStore.getValue("notificationBodyEnabled")}
onChange={this.onEnableDesktopNotificationBodyChange}
label={_t('Show message in desktop notification')} />
onChange={this.onEnableDesktopNotificationBodyChange}
label={_t('Show message in desktop notification')} />
<LabelledToggleSwitch value={SettingsStore.getValue("audioNotificationsEnabled")}
onChange={this.onEnableAudioNotificationsChange}
label={_t('Enable audible notifications for this session')} />
onChange={this.onEnableAudioNotificationsChange}
label={_t('Enable audible notifications for this session')} />
{ emailNotificationsRows }

View file

@ -170,8 +170,12 @@ export default class ProfileSettings extends React.Component {
noValidate={true}
className="mx_ProfileSettings_profileForm"
>
<input type="file" ref={this._avatarUpload} className="mx_ProfileSettings_avatarUpload"
onChange={this._onAvatarChanged} accept="image/*" />
<input
type="file"
ref={this._avatarUpload} className="mx_ProfileSettings_avatarUpload"
onChange={this._onAvatarChanged}
accept="image/*"
/>
<div className="mx_ProfileSettings_profile">
<div className="mx_ProfileSettings_controls">
<span className="mx_SettingsTab_subheading">{_t("Profile")}</span>

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019-2021 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.
@ -16,7 +16,6 @@ limitations under the License.
import url from 'url';
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import * as sdk from '../../../index';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
@ -28,6 +27,7 @@ import {abbreviateUrl, unabbreviateUrl} from "../../../utils/UrlUtils";
import { getDefaultIdentityServerUrl, doesIdentityServerHaveTerms } from '../../../utils/IdentityServerUtils';
import {timeout} from "../../../utils/promise";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { ActionPayload } from '../../../dispatcher/payloads';
// We'll wait up to this long when checking for 3PID bindings on the IS.
const REACHABILITY_TIMEOUT = 10000; // ms
@ -59,16 +59,28 @@ async function checkIdentityServerUrl(u) {
}
}
@replaceableComponent("views.settings.SetIdServer")
export default class SetIdServer extends React.Component {
static propTypes = {
// Whether or not the ID server is missing terms. This affects the text
// shown to the user.
missingTerms: PropTypes.bool,
};
interface IProps {
// Whether or not the ID server is missing terms. This affects the text
// shown to the user.
missingTerms: boolean;
}
constructor() {
super();
interface IState {
defaultIdServer?: string;
currentClientIdServer: string;
idServer?: string;
error?: string;
busy: boolean;
disconnectBusy: boolean;
checking: boolean;
}
@replaceableComponent("views.settings.SetIdServer")
export default class SetIdServer extends React.Component<IProps, IState> {
private dispatcherRef: string;
constructor(props) {
super(props);
let defaultIdServer = '';
if (!MatrixClientPeg.get().getIdentityServerUrl() && getDefaultIdentityServerUrl()) {
@ -96,7 +108,7 @@ export default class SetIdServer extends React.Component {
dis.unregister(this.dispatcherRef);
}
onAction = (payload) => {
private onAction = (payload: ActionPayload) => {
// We react to changes in the ID server in the event the user is staring at this form
// when changing their identity server on another device.
if (payload.action !== "id_server_changed") return;
@ -106,13 +118,13 @@ export default class SetIdServer extends React.Component {
});
};
_onIdentityServerChanged = (ev) => {
private onIdentityServerChanged = (ev) => {
const u = ev.target.value;
this.setState({idServer: u});
};
_getTooltip = () => {
private getTooltip = () => {
if (this.state.checking) {
const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner');
return <div>
@ -126,11 +138,11 @@ export default class SetIdServer extends React.Component {
}
};
_idServerChangeEnabled = () => {
private idServerChangeEnabled = () => {
return !!this.state.idServer && !this.state.busy;
};
_saveIdServer = (fullUrl) => {
private saveIdServer = (fullUrl) => {
// Account data change will update localstorage, client, etc through dispatcher
MatrixClientPeg.get().setAccountData("m.identity_server", {
base_url: fullUrl,
@ -143,7 +155,7 @@ export default class SetIdServer extends React.Component {
});
};
_checkIdServer = async (e) => {
private checkIdServer = async (e) => {
e.preventDefault();
const { idServer, currentClientIdServer } = this.state;
@ -166,14 +178,14 @@ export default class SetIdServer extends React.Component {
// Double check that the identity server even has terms of service.
const hasTerms = await doesIdentityServerHaveTerms(fullUrl);
if (!hasTerms) {
const [confirmed] = await this._showNoTermsWarning(fullUrl);
const [confirmed] = await this.showNoTermsWarning(fullUrl);
save = confirmed;
}
// Show a general warning, possibly with details about any bound
// 3PIDs that would be left behind.
if (save && currentClientIdServer && fullUrl !== currentClientIdServer) {
const [confirmed] = await this._showServerChangeWarning({
const [confirmed] = await this.showServerChangeWarning({
title: _t("Change identity server"),
unboundMessage: _t(
"Disconnect from the identity server <current /> and " +
@ -189,7 +201,7 @@ export default class SetIdServer extends React.Component {
}
if (save) {
this._saveIdServer(fullUrl);
this.saveIdServer(fullUrl);
}
} catch (e) {
console.error(e);
@ -204,7 +216,7 @@ export default class SetIdServer extends React.Component {
});
};
_showNoTermsWarning(fullUrl) {
private showNoTermsWarning(fullUrl) {
const QuestionDialog = sdk.getComponent("views.dialogs.QuestionDialog");
const { finished } = Modal.createTrackedDialog('No Terms Warning', '', QuestionDialog, {
title: _t("Identity server has no terms of service"),
@ -223,10 +235,10 @@ export default class SetIdServer extends React.Component {
return finished;
}
_onDisconnectClicked = async () => {
private onDisconnectClicked = async () => {
this.setState({disconnectBusy: true});
try {
const [confirmed] = await this._showServerChangeWarning({
const [confirmed] = await this.showServerChangeWarning({
title: _t("Disconnect identity server"),
unboundMessage: _t(
"Disconnect from the identity server <idserver />?", {},
@ -235,14 +247,14 @@ export default class SetIdServer extends React.Component {
button: _t("Disconnect"),
});
if (confirmed) {
this._disconnectIdServer();
this.disconnectIdServer();
}
} finally {
this.setState({disconnectBusy: false});
}
};
async _showServerChangeWarning({ title, unboundMessage, button }) {
private async showServerChangeWarning({ title, unboundMessage, button }) {
const { currentClientIdServer } = this.state;
let threepids = [];
@ -318,7 +330,7 @@ export default class SetIdServer extends React.Component {
return finished;
}
_disconnectIdServer = () => {
private disconnectIdServer = () => {
// Account data change will update localstorage, client, etc through dispatcher
MatrixClientPeg.get().setAccountData("m.identity_server", {
base_url: null, // clear
@ -371,7 +383,7 @@ export default class SetIdServer extends React.Component {
let discoSection;
if (idServerUrl) {
let discoButtonContent = _t("Disconnect");
let discoButtonContent: React.ReactNode = _t("Disconnect");
let discoBodyText = _t(
"Disconnecting from your identity server will mean you " +
"won't be discoverable by other users and you won't be " +
@ -391,14 +403,14 @@ export default class SetIdServer extends React.Component {
}
discoSection = <div>
<span className="mx_SettingsTab_subsectionText">{discoBodyText}</span>
<AccessibleButton onClick={this._onDisconnectClicked} kind="danger_sm">
<AccessibleButton onClick={this.onDisconnectClicked} kind="danger_sm">
{discoButtonContent}
</AccessibleButton>
</div>;
}
return (
<form className="mx_SettingsTab_section mx_SetIdServer" onSubmit={this._checkIdServer}>
<form className="mx_SettingsTab_section mx_SetIdServer" onSubmit={this.checkIdServer}>
<span className="mx_SettingsTab_subheading">
{sectionTitle}
</span>
@ -411,15 +423,15 @@ export default class SetIdServer extends React.Component {
autoComplete="off"
placeholder={this.state.defaultIdServer}
value={this.state.idServer}
onChange={this._onIdentityServerChanged}
tooltipContent={this._getTooltip()}
onChange={this.onIdentityServerChanged}
tooltipContent={this.getTooltip()}
tooltipClassName="mx_SetIdServer_tooltip"
disabled={this.state.busy}
forceValidity={this.state.error ? false : null}
/>
<AccessibleButton type="submit" kind="primary_sm"
onClick={this._checkIdServer}
disabled={!this._idServerChangeEnabled()}
onClick={this.checkIdServer}
disabled={!this.idServerChangeEnabled()}
>{_t("Change")}</AccessibleButton>
{discoSection}
</form>

View file

@ -90,12 +90,18 @@ export class ExistingEmailAddress extends React.Component {
<span className="mx_ExistingEmailAddress_promptText">
{_t("Remove %(email)s?", {email: this.props.email.address} )}
</span>
<AccessibleButton onClick={this._onActuallyRemove} kind="danger_sm"
className="mx_ExistingEmailAddress_confirmBtn">
<AccessibleButton
onClick={this._onActuallyRemove}
kind="danger_sm"
className="mx_ExistingEmailAddress_confirmBtn"
>
{_t("Remove")}
</AccessibleButton>
<AccessibleButton onClick={this._onDontRemove} kind="link_sm"
className="mx_ExistingEmailAddress_confirmBtn">
<AccessibleButton
onClick={this._onDontRemove}
kind="link_sm"
className="mx_ExistingEmailAddress_confirmBtn"
>
{_t("Cancel")}
</AccessibleButton>
</div>
@ -228,21 +234,28 @@ export default class EmailAddresses extends React.Component {
);
if (this.state.verifying) {
addButton = (
<div>
<div>{_t("We've sent you an email to verify your address. Please follow the instructions there and then click the button below.")}</div>
<AccessibleButton onClick={this._onContinueClick} kind="primary"
disabled={this.state.continueDisabled}>
{_t("Continue")}
</AccessibleButton>
</div>
<div>
<div>{_t("We've sent you an email to verify your address. Please follow the instructions there and then click the button below.")}</div>
<AccessibleButton
onClick={this._onContinueClick}
kind="primary"
disabled={this.state.continueDisabled}
>
{_t("Continue")}
</AccessibleButton>
</div>
);
}
return (
<div className="mx_EmailAddresses">
{existingEmailElements}
<form onSubmit={this._onAddClick} autoComplete="off"
noValidate={true} className="mx_EmailAddresses_new">
<form
onSubmit={this._onAddClick}
autoComplete="off"
noValidate={true}
className="mx_EmailAddresses_new"
>
<Field
type="text"
label={_t("Email Address")}

View file

@ -85,12 +85,18 @@ export class ExistingPhoneNumber extends React.Component {
<span className="mx_ExistingPhoneNumber_promptText">
{_t("Remove %(phone)s?", {phone: this.props.msisdn.address})}
</span>
<AccessibleButton onClick={this._onActuallyRemove} kind="danger_sm"
className="mx_ExistingPhoneNumber_confirmBtn">
<AccessibleButton
onClick={this._onActuallyRemove}
kind="danger_sm"
className="mx_ExistingPhoneNumber_confirmBtn"
>
{_t("Remove")}
</AccessibleButton>
<AccessibleButton onClick={this._onDontRemove} kind="link_sm"
className="mx_ExistingPhoneNumber_confirmBtn">
<AccessibleButton
onClick={this._onDontRemove}
kind="link_sm"
className="mx_ExistingPhoneNumber_confirmBtn"
>
{_t("Cancel")}
</AccessibleButton>
</div>
@ -246,8 +252,11 @@ export default class PhoneNumbers extends React.Component {
value={this.state.newPhoneNumberCode}
onChange={this._onChangeNewPhoneNumberCode}
/>
<AccessibleButton onClick={this._onContinueClick} kind="primary"
disabled={this.state.continueDisabled}>
<AccessibleButton
onClick={this._onContinueClick}
kind="primary"
disabled={this.state.continueDisabled}
>
{_t("Continue")}
</AccessibleButton>
</form>

View file

@ -80,9 +80,11 @@ export default class GeneralRoomSettingsTab extends React.Component {
flairSection = <>
<span className='mx_SettingsTab_subheading'>{_t("Flair")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<RelatedGroupSettings roomId={room.roomId}
canSetRelatedGroups={canChangeGroups}
relatedGroupsEvent={groupsEvent} />
<RelatedGroupSettings
roomId={room.roomId}
canSetRelatedGroups={canChangeGroups}
relatedGroupsEvent={groupsEvent}
/>
</div>
</>;
}
@ -97,8 +99,8 @@ export default class GeneralRoomSettingsTab extends React.Component {
<div className="mx_SettingsTab_heading">{_t("Room Addresses")}</div>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<AliasSettings roomId={this.props.roomId}
canSetCanonicalAlias={canSetCanonical} canSetAliases={canSetAliases}
canonicalAliasEvent={canonicalAliasEv} aliasEvents={aliasEvents} />
canSetCanonicalAlias={canSetCanonical} canSetAliases={canSetAliases}
canonicalAliasEvent={canonicalAliasEv} aliasEvents={aliasEvents} />
</div>
<div className="mx_SettingsTab_heading">{_t("Other")}</div>
{ flairSection }

View file

@ -155,7 +155,7 @@ export default class NotificationsSettingsTab extends React.Component {
<div>
<span>{_t("Notification sound")}: <code>{this.state.currentSound}</code></span><br />
<AccessibleButton className="mx_NotificationSound_resetSound" disabled={this.state.currentSound == "default"} onClick={this._clearSound.bind(this)} kind="primary">
{_t("Reset")}
{_t("Reset")}
</AccessibleButton>
</div>
<div>
@ -167,11 +167,11 @@ export default class NotificationsSettingsTab extends React.Component {
{currentUploadedFile}
<AccessibleButton className="mx_NotificationSound_browse" onClick={this._triggerUploader.bind(this)} kind="primary">
{_t("Browse")}
{_t("Browse")}
</AccessibleButton>
<AccessibleButton className="mx_NotificationSound_save" disabled={this.state.uploadedFile == null} onClick={this._onClickSaveSound.bind(this)} kind="primary">
{_t("Save")}
{_t("Save")}
</AccessibleButton>
<br />
</div>

View file

@ -1,5 +1,5 @@
/*
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2019-2021 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.
@ -15,7 +15,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {_t, _td} from "../../../../../languageHandler";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import * as sdk from "../../../../..";
@ -23,6 +22,9 @@ import AccessibleButton from "../../../elements/AccessibleButton";
import Modal from "../../../../../Modal";
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
import {EventType} from "matrix-js-sdk/src/@types/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomState } from "matrix-js-sdk/src/models/room-state";
const plEventsToLabels = {
// These will be translated for us later.
@ -63,15 +65,15 @@ function parseIntWithDefault(val, def) {
return isNaN(res) ? def : res;
}
export class BannedUser extends React.Component {
static propTypes = {
canUnban: PropTypes.bool,
member: PropTypes.object.isRequired, // js-sdk RoomMember
by: PropTypes.string.isRequired,
reason: PropTypes.string,
};
interface IBannedUserProps {
canUnban?: boolean;
member: RoomMember;
by: string;
reason?: string;
}
_onUnbanClick = (e) => {
export class BannedUser extends React.Component<IBannedUserProps> {
private onUnbanClick = (e) => {
MatrixClientPeg.get().unban(this.props.member.roomId, this.props.member.userId).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to unban: " + err);
@ -87,8 +89,10 @@ export class BannedUser extends React.Component {
if (this.props.canUnban) {
unbanButton = (
<AccessibleButton kind='danger_sm' onClick={this._onUnbanClick}
className='mx_RolesRoomSettingsTab_unbanBtn'>
<AccessibleButton className='mx_RolesRoomSettingsTab_unbanBtn'
kind='danger_sm'
onClick={this.onUnbanClick}
>
{ _t('Unban') }
</AccessibleButton>
);
@ -107,29 +111,29 @@ export class BannedUser extends React.Component {
}
}
@replaceableComponent("views.settings.tabs.room.RolesRoomSettingsTab")
export default class RolesRoomSettingsTab extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
};
interface IProps {
roomId: string;
}
componentDidMount(): void {
MatrixClientPeg.get().on("RoomState.members", this._onRoomMembership);
@replaceableComponent("views.settings.tabs.room.RolesRoomSettingsTab")
export default class RolesRoomSettingsTab extends React.Component<IProps> {
componentDidMount() {
MatrixClientPeg.get().on("RoomState.members", this.onRoomMembership);
}
componentWillUnmount(): void {
componentWillUnmount() {
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("RoomState.members", this._onRoomMembership);
client.removeListener("RoomState.members", this.onRoomMembership);
}
}
_onRoomMembership = (event, state, member) => {
private onRoomMembership = (event: MatrixEvent, state: RoomState, member: RoomMember) => {
if (state.roomId !== this.props.roomId) return;
this.forceUpdate();
};
_populateDefaultPlEvents(eventsSection, stateLevel, eventsLevel) {
private populateDefaultPlEvents(eventsSection: Record<string, number>, stateLevel: number, eventsLevel: number) {
for (const desiredEvent of Object.keys(plEventsToShow)) {
if (!(desiredEvent in eventsSection)) {
eventsSection[desiredEvent] = (plEventsToShow[desiredEvent].isState ? stateLevel : eventsLevel);
@ -137,7 +141,7 @@ export default class RolesRoomSettingsTab extends React.Component {
}
}
_onPowerLevelsChanged = (value, powerLevelKey) => {
private onPowerLevelsChanged = (inputValue: string, powerLevelKey: string) => {
const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
@ -148,7 +152,7 @@ export default class RolesRoomSettingsTab extends React.Component {
const eventsLevelPrefix = "event_levels_";
value = parseInt(value);
const value = parseInt(inputValue);
if (powerLevelKey.startsWith(eventsLevelPrefix)) {
// deep copy "events" object, Object.assign itself won't deep copy
@ -182,7 +186,7 @@ export default class RolesRoomSettingsTab extends React.Component {
});
};
_onUserPowerLevelChanged = (value, powerLevelKey) => {
private onUserPowerLevelChanged = (value: string, powerLevelKey: string) => {
const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
@ -266,7 +270,7 @@ export default class RolesRoomSettingsTab extends React.Component {
currentUserLevel = defaultUserLevel;
}
this._populateDefaultPlEvents(
this.populateDefaultPlEvents(
eventsLevels,
parseIntWithDefault(plContent.state_default, powerLevelDescriptors.state_default.defaultValue),
parseIntWithDefault(plContent.events_default, powerLevelDescriptors.events_default.defaultValue),
@ -288,7 +292,7 @@ export default class RolesRoomSettingsTab extends React.Component {
label={user}
key={user}
powerLevelKey={user} // Will be sent as the second parameter to `onChange`
onChange={this._onUserPowerLevelChanged}
onChange={this.onUserPowerLevelChanged}
/>,
);
} else if (userLevels[user] < defaultUserLevel) { // muted
@ -299,7 +303,7 @@ export default class RolesRoomSettingsTab extends React.Component {
label={user}
key={user}
powerLevelKey={user} // Will be sent as the second parameter to `onChange`
onChange={this._onUserPowerLevelChanged}
onChange={this.onUserPowerLevelChanged}
/>,
);
}
@ -345,8 +349,9 @@ export default class RolesRoomSettingsTab extends React.Component {
if (sender) bannedBy = sender.name;
return (
<BannedUser key={member.userId} canUnban={canBanUsers}
member={member} reason={banEvent.reason}
by={bannedBy} />
member={member} reason={banEvent.reason}
by={bannedBy}
/>
);
})}
</ul>
@ -373,7 +378,7 @@ export default class RolesRoomSettingsTab extends React.Component {
usersDefault={defaultUserLevel}
disabled={!canChangeLevels || currentUserLevel < value}
powerLevelKey={key} // Will be sent as the second parameter to `onChange`
onChange={this._onPowerLevelsChanged}
onChange={this.onPowerLevelsChanged}
/>
</div>;
});
@ -398,7 +403,7 @@ export default class RolesRoomSettingsTab extends React.Component {
usersDefault={defaultUserLevel}
disabled={!canChangeLevels || currentUserLevel < eventsLevels[eventType]}
powerLevelKey={"event_levels_" + eventType}
onChange={this._onPowerLevelsChanged}
onChange={this.onPowerLevelsChanged}
/>
</div>
);

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019-2021 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.
@ -15,7 +15,7 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import {_t} from "../../../../../languageHandler";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import * as sdk from "../../../../..";
@ -26,64 +26,92 @@ import StyledRadioGroup from '../../../elements/StyledRadioGroup';
import {SettingLevel} from "../../../../../settings/SettingLevel";
import SettingsStore from "../../../../../settings/SettingsStore";
import {UIFeature} from "../../../../../settings/UIFeature";
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
// Knock and private are reserved keywords which are not yet implemented.
enum JoinRule {
Public = "public",
Knock = "knock",
Invite = "invite",
Private = "private",
}
enum GuestAccess {
CanJoin = "can_join",
Forbidden = "forbidden",
}
enum HistoryVisibility {
Invited = "invited",
Joined = "joined",
Shared = "shared",
WorldReadable = "world_readable",
}
interface IProps {
roomId: string;
}
interface IState {
joinRule: JoinRule;
guestAccess: GuestAccess;
history: HistoryVisibility;
hasAliases: boolean;
encrypted: boolean;
}
@replaceableComponent("views.settings.tabs.room.SecurityRoomSettingsTab")
export default class SecurityRoomSettingsTab extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
};
constructor() {
super();
export default class SecurityRoomSettingsTab extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
joinRule: "invite",
guestAccess: "can_join",
history: "shared",
joinRule: JoinRule.Invite,
guestAccess: GuestAccess.CanJoin,
history: HistoryVisibility.Shared,
hasAliases: false,
encrypted: false,
};
}
// TODO: [REACT-WARNING] Move this to constructor
async UNSAFE_componentWillMount(): void { // eslint-disable-line camelcase
MatrixClientPeg.get().on("RoomState.events", this._onStateEvent);
async UNSAFE_componentWillMount() { // eslint-disable-line camelcase
MatrixClientPeg.get().on("RoomState.events", this.onStateEvent);
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
const state = room.currentState;
const joinRule = this._pullContentPropertyFromEvent(
const joinRule: JoinRule = this.pullContentPropertyFromEvent(
state.getStateEvents("m.room.join_rules", ""),
'join_rule',
'invite',
JoinRule.Invite,
);
const guestAccess = this._pullContentPropertyFromEvent(
const guestAccess: GuestAccess = this.pullContentPropertyFromEvent(
state.getStateEvents("m.room.guest_access", ""),
'guest_access',
'forbidden',
GuestAccess.Forbidden,
);
const history = this._pullContentPropertyFromEvent(
const history: HistoryVisibility = this.pullContentPropertyFromEvent(
state.getStateEvents("m.room.history_visibility", ""),
'history_visibility',
'shared',
HistoryVisibility.Shared,
);
const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
this.setState({joinRule, guestAccess, history, encrypted});
const hasAliases = await this._hasAliases();
const hasAliases = await this.hasAliases();
this.setState({hasAliases});
}
_pullContentPropertyFromEvent(event, key, defaultValue) {
private pullContentPropertyFromEvent<T>(event: MatrixEvent, key: string, defaultValue: T): T {
if (!event || !event.getContent()) return defaultValue;
return event.getContent()[key] || defaultValue;
}
componentWillUnmount(): void {
MatrixClientPeg.get().removeListener("RoomState.events", this._onStateEvent);
componentWillUnmount() {
MatrixClientPeg.get().removeListener("RoomState.events", this.onStateEvent);
}
_onStateEvent = (e) => {
private onStateEvent = (e: MatrixEvent) => {
const refreshWhenTypes = [
'm.room.join_rules',
'm.room.guest_access',
@ -93,7 +121,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
if (refreshWhenTypes.includes(e.getType())) this.forceUpdate();
};
_onEncryptionChange = (e) => {
private onEncryptionChange = (e: React.ChangeEvent) => {
Modal.createTrackedDialog('Enable encryption', '', QuestionDialog, {
title: _t('Enable encryption?'),
description: _t(
@ -102,10 +130,9 @@ export default class SecurityRoomSettingsTab extends React.Component {
"may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>",
{},
{
'a': (sub) => {
return <a rel='noreferrer noopener' target='_blank'
href='https://element.io/help#encryption'>{sub}</a>;
},
a: sub => <a href="https://element.io/help#encryption"
rel="noreferrer noopener" target="_blank"
>{sub}</a>,
},
),
onFinished: (confirm) => {
@ -127,12 +154,12 @@ export default class SecurityRoomSettingsTab extends React.Component {
});
};
_fixGuestAccess = (e) => {
private fixGuestAccess = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const joinRule = "invite";
const guestAccess = "can_join";
const joinRule = JoinRule.Invite;
const guestAccess = GuestAccess.CanJoin;
const beforeJoinRule = this.state.joinRule;
const beforeGuestAccess = this.state.guestAccess;
@ -149,7 +176,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
});
};
_onRoomAccessRadioToggle = (roomAccess) => {
private onRoomAccessRadioToggle = (roomAccess: string) => {
// join_rule
// INVITE | PUBLIC
// ----------------------+----------------
@ -163,20 +190,20 @@ export default class SecurityRoomSettingsTab extends React.Component {
// invite them, you clearly want them to join, whether they're a
// guest or not. In practice, guest_access should probably have
// been implemented as part of the join_rules enum.
let joinRule = "invite";
let guestAccess = "can_join";
let joinRule = JoinRule.Invite;
let guestAccess = GuestAccess.CanJoin;
switch (roomAccess) {
case "invite_only":
// no change - use defaults above
break;
case "public_no_guests":
joinRule = "public";
guestAccess = "forbidden";
joinRule = JoinRule.Public;
guestAccess = GuestAccess.Forbidden;
break;
case "public_with_guests":
joinRule = "public";
guestAccess = "can_join";
joinRule = JoinRule.Public;
guestAccess = GuestAccess.CanJoin;
break;
}
@ -195,7 +222,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
});
};
_onHistoryRadioToggle = (history) => {
private onHistoryRadioToggle = (history: HistoryVisibility) => {
const beforeHistory = this.state.history;
this.setState({history: history});
MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.history_visibility", {
@ -206,11 +233,11 @@ export default class SecurityRoomSettingsTab extends React.Component {
});
};
_updateBlacklistDevicesFlag = (checked) => {
private updateBlacklistDevicesFlag = (checked: boolean) => {
MatrixClientPeg.get().getRoom(this.props.roomId).setBlacklistUnverifiedDevices(checked);
};
async _hasAliases() {
private async hasAliases(): Promise<boolean> {
const cli = MatrixClientPeg.get();
if (await cli.doesServerSupportUnstableFeature("org.matrix.msc2432")) {
const response = await cli.unstableGetLocalAliases(this.props.roomId);
@ -224,7 +251,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
}
}
_renderRoomAccess() {
private renderRoomAccess() {
const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId);
const joinRule = this.state.joinRule;
@ -240,7 +267,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
<img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} />
<span>
{_t("Guests cannot join this room even if explicitly invited.")}&nbsp;
<a href="" onClick={this._fixGuestAccess}>{_t("Click here to fix")}</a>
<a href="" onClick={this.fixGuestAccess}>{_t("Click here to fix")}</a>
</span>
</div>
);
@ -265,7 +292,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
<StyledRadioGroup
name="roomVis"
value={joinRule}
onChange={this._onRoomAccessRadioToggle}
onChange={this.onRoomAccessRadioToggle}
definitions={[
{
value: "invite_only",
@ -291,7 +318,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
);
}
_renderHistory() {
private renderHistory() {
const client = MatrixClientPeg.get();
const history = this.state.history;
const state = client.getRoom(this.props.roomId).currentState;
@ -306,25 +333,25 @@ export default class SecurityRoomSettingsTab extends React.Component {
<StyledRadioGroup
name="historyVis"
value={history}
onChange={this._onHistoryRadioToggle}
onChange={this.onHistoryRadioToggle}
definitions={[
{
value: "world_readable",
value: HistoryVisibility.WorldReadable,
disabled: !canChangeHistory,
label: _t("Anyone"),
},
{
value: "shared",
value: HistoryVisibility.Shared,
disabled: !canChangeHistory,
label: _t('Members only (since the point in time of selecting this option)'),
},
{
value: "invited",
value: HistoryVisibility.Invited,
disabled: !canChangeHistory,
label: _t('Members only (since they were invited)'),
},
{
value: "joined",
value: HistoryVisibility.Joined,
disabled: !canChangeHistory,
label: _t('Members only (since they joined)'),
},
@ -348,7 +375,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
encryptionSettings = <SettingsFlag
name="blacklistUnverifiedDevices"
level={SettingLevel.ROOM_DEVICE}
onChange={this._updateBlacklistDevicesFlag}
onChange={this.updateBlacklistDevicesFlag}
roomId={this.props.roomId}
/>;
}
@ -356,7 +383,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
let historySection = (<>
<span className='mx_SettingsTab_subheading'>{_t("Who can read history?")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
{this._renderHistory()}
{this.renderHistory()}
</div>
</>);
if (!SettingsStore.getValue(UIFeature.RoomHistorySettings)) {
@ -373,15 +400,16 @@ export default class SecurityRoomSettingsTab extends React.Component {
<div className='mx_SettingsTab_subsectionText'>
<span>{_t("Once enabled, encryption cannot be disabled.")}</span>
</div>
<LabelledToggleSwitch value={isEncrypted} onChange={this._onEncryptionChange}
label={_t("Encrypted")} disabled={!canEnableEncryption} />
<LabelledToggleSwitch value={isEncrypted} onChange={this.onEncryptionChange}
label={_t("Encrypted")} disabled={!canEnableEncryption}
/>
</div>
{encryptionSettings}
</div>
<span className='mx_SettingsTab_subheading'>{_t("Who can access this room?")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
{this._renderRoomAccess()}
{this.renderRoomAccess()}
</div>
{historySection}

View file

@ -192,7 +192,11 @@ export default class GeneralUserSettingsTab extends React.Component {
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage);
this.setState({language: newLanguage});
PlatformPeg.get().reload();
const platform = PlatformPeg.get();
if (platform) {
platform.setLanguage(newLanguage);
platform.reload();
}
};
_onSpellCheckLanguagesChange = (languages) => {
@ -319,8 +323,11 @@ export default class GeneralUserSettingsTab extends React.Component {
return (
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Language and region")}</span>
<LanguageDropdown className="mx_GeneralUserSettingsTab_languageInput"
onOptionChange={this._onLanguageChange} value={this.state.language} />
<LanguageDropdown
className="mx_GeneralUserSettingsTab_languageInput"
onOptionChange={this._onLanguageChange}
value={this.state.language}
/>
</div>
);
}
@ -329,8 +336,10 @@ export default class GeneralUserSettingsTab extends React.Component {
return (
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Spell check dictionaries")}</span>
<SpellCheckSettings languages={this.state.spellCheckLanguages}
onLanguagesChange={this._onSpellCheckLanguagesChange} />
<SpellCheckSettings
languages={this.state.spellCheckLanguages}
onLanguagesChange={this._onSpellCheckLanguagesChange}
/>
</div>
);
}

View file

@ -1,6 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019-2021 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.
@ -16,27 +15,37 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {_t, getCurrentLanguage} from "../../../../../languageHandler";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton";
import AccessibleTooltipButton from '../../../elements/AccessibleTooltipButton';
import SdkConfig from "../../../../../SdkConfig";
import createRoom from "../../../../../createRoom";
import Modal from "../../../../../Modal";
import * as sdk from "../../../../../";
import * as sdk from "../../../../..";
import PlatformPeg from "../../../../../PlatformPeg";
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import UpdateCheckButton from "../../UpdateCheckButton";
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import { copyPlaintext } from "../../../../../utils/strings";
import * as ContextMenu from "../../../../structures/ContextMenu";
import { toRightOf } from "../../../../structures/ContextMenu";
interface IProps {
closeSettingsFn: () => {};
}
interface IState {
appVersion: string;
canUpdate: boolean;
}
@replaceableComponent("views.settings.tabs.user.HelpUserSettingsTab")
export default class HelpUserSettingsTab extends React.Component {
static propTypes = {
closeSettingsFn: PropTypes.func.isRequired,
};
export default class HelpUserSettingsTab extends React.Component<IProps, IState> {
protected closeCopiedTooltip: () => void;
constructor() {
super();
constructor(props) {
super(props);
this.state = {
appVersion: null,
@ -53,7 +62,13 @@ export default class HelpUserSettingsTab extends React.Component {
});
}
_onClearCacheAndReload = (e) => {
componentWillUnmount() {
// if the Copied tooltip is open then get rid of it, there are ways to close the modal which wouldn't close
// the tooltip otherwise, such as pressing Escape
if (this.closeCopiedTooltip) this.closeCopiedTooltip();
}
private onClearCacheAndReload = (e) => {
if (!PlatformPeg.get()) return;
// Dev note: please keep this log line, it's useful when troubleshooting a MatrixClient suddenly
@ -65,7 +80,7 @@ export default class HelpUserSettingsTab extends React.Component {
});
};
_onBugReport = (e) => {
private onBugReport = (e) => {
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
if (!BugReportDialog) {
return;
@ -73,7 +88,7 @@ export default class HelpUserSettingsTab extends React.Component {
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
};
_onStartBotChat = (e) => {
private onStartBotChat = (e) => {
this.props.closeSettingsFn();
createRoom({
dmUserId: SdkConfig.get().welcomeUserId,
@ -81,7 +96,7 @@ export default class HelpUserSettingsTab extends React.Component {
});
};
_showSpoiler = (event) => {
private showSpoiler = (event) => {
const target = event.target;
target.innerHTML = target.getAttribute('data-spoiler');
@ -93,7 +108,7 @@ export default class HelpUserSettingsTab extends React.Component {
selection.addRange(range);
};
_renderLegal() {
private renderLegal() {
const tocLinks = SdkConfig.get().terms_and_conditions_links;
if (!tocLinks) return null;
@ -114,7 +129,7 @@ export default class HelpUserSettingsTab extends React.Component {
);
}
_renderCredits() {
private renderCredits() {
// Note: This is not translated because it is legal text.
// Also, &nbsp; is ugly but necessary.
return (
@ -122,34 +137,48 @@ export default class HelpUserSettingsTab extends React.Component {
<span className='mx_SettingsTab_subheading'>{_t("Credits")}</span>
<ul>
<li>
The <a href="themes/element/img/backgrounds/lake.jpg" rel="noreferrer noopener" target="_blank">
default cover photo</a> is ©&nbsp;
<a href="https://www.flickr.com/golan" rel="noreferrer noopener" target="_blank">Jesús Roncero</a>{' '}
used under the terms of&nbsp;
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noreferrer noopener" target="_blank">
CC-BY-SA 4.0</a>.
The <a href="themes/element/img/backgrounds/lake.jpg" rel="noreferrer noopener"
target="_blank">default cover photo</a> is ©&nbsp;
<a href="https://www.flickr.com/golan" rel="noreferrer noopener"
target="_blank">Jesús Roncero</a> used under the terms of&nbsp;
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noreferrer noopener"
target="_blank">CC-BY-SA 4.0</a>.
</li>
<li>
The <a href="https://github.com/matrix-org/twemoji-colr" rel="noreferrer noopener"
target="_blank"> twemoji-colr</a> font is ©&nbsp;
<a href="https://mozilla.org" rel="noreferrer noopener" target="_blank">Mozilla Foundation</a>{' '}
used under the terms of&nbsp;
<a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener" target="_blank">
Apache 2.0</a>.
target="_blank">twemoji-colr</a> font is ©&nbsp;
<a href="https://mozilla.org" rel="noreferrer noopener"
target="_blank">Mozilla Foundation</a> used under the terms of&nbsp;
<a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener"
target="_blank">Apache 2.0</a>.
</li>
<li>
The <a href="https://twemoji.twitter.com/" rel="noreferrer noopener" target="_blank">
Twemoji</a> emoji art is ©&nbsp;
<a href="https://twemoji.twitter.com/" rel="noreferrer noopener" target="_blank">Twitter, Inc and other
contributors</a> used under the terms of&nbsp;
<a href="https://creativecommons.org/licenses/by/4.0/" rel="noreferrer noopener" target="_blank">
CC-BY 4.0</a>.
The <a href="https://twemoji.twitter.com/" rel="noreferrer noopener"
target="_blank">Twemoji</a> emoji art is ©&nbsp;
<a href="https://twemoji.twitter.com/" rel="noreferrer noopener"
target="_blank">Twitter, Inc and other contributors</a> used under the terms of&nbsp;
<a href="https://creativecommons.org/licenses/by/4.0/" rel="noreferrer noopener"
target="_blank">CC-BY 4.0</a>.
</li>
</ul>
</div>
);
}
onAccessTokenCopyClick = async (e) => {
e.preventDefault();
const target = e.target; // copy target before we go async and React throws it away
const successful = await copyPlaintext(MatrixClientPeg.get().getAccessToken());
const buttonRect = target.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
this.closeCopiedTooltip = target.onmouseleave = close;
}
render() {
const brand = SdkConfig.get().brand;
@ -188,7 +217,7 @@ export default class HelpUserSettingsTab extends React.Component {
},
)}
<div>
<AccessibleButton onClick={this._onStartBotChat} kind='primary'>
<AccessibleButton onClick={this.onStartBotChat} kind='primary'>
{_t("Chat with %(brand)s Bot", { brand })}
</AccessibleButton>
</div>
@ -212,28 +241,27 @@ export default class HelpUserSettingsTab extends React.Component {
<div className="mx_SettingsTab_section">
<span className='mx_SettingsTab_subheading'>{_t('Bug reporting')}</span>
<div className='mx_SettingsTab_subsectionText'>
{
_t( "If you've submitted a bug via GitHub, debug logs can help " +
"us track down the problem. Debug logs contain application " +
"usage data including your username, the IDs or aliases of " +
"the rooms or groups you have visited and the usernames of " +
"other users. They do not contain messages.",
)
}
{_t(
"If you've submitted a bug via GitHub, debug logs can help " +
"us track down the problem. Debug logs contain application " +
"usage data including your username, the IDs or aliases of " +
"the rooms or groups you have visited and the usernames of " +
"other users. They do not contain messages.",
)}
<div className='mx_HelpUserSettingsTab_debugButton'>
<AccessibleButton onClick={this._onBugReport} kind='primary'>
<AccessibleButton onClick={this.onBugReport} kind='primary'>
{_t("Submit debug logs")}
</AccessibleButton>
</div>
{
_t( "To report a Matrix-related security issue, please read the Matrix.org " +
"<a>Security Disclosure Policy</a>.", {},
{
'a': (sub) =>
<a href="https://matrix.org/security-disclosure-policy/"
rel="noreferrer noopener" target="_blank">{sub}</a>,
})
}
{_t(
"To report a Matrix-related security issue, please read the Matrix.org " +
"<a>Security Disclosure Policy</a>.", {},
{
a: sub => <a href="https://matrix.org/security-disclosure-policy/"
rel="noreferrer noopener" target="_blank"
>{sub}</a>,
},
)}
</div>
</div>
);
@ -260,20 +288,29 @@ export default class HelpUserSettingsTab extends React.Component {
{updateButton}
</div>
</div>
{this._renderLegal()}
{this._renderCredits()}
{this.renderLegal()}
{this.renderCredits()}
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
<span className='mx_SettingsTab_subheading'>{_t("Advanced")}</span>
<div className='mx_SettingsTab_subsectionText'>
{_t("Homeserver is")} <code>{MatrixClientPeg.get().getHomeserverUrl()}</code><br />
{_t("Identity Server is")} <code>{MatrixClientPeg.get().getIdentityServerUrl()}</code><br />
{_t("Access Token:") + ' '}
<AccessibleButton element="span" onClick={this._showSpoiler}
data-spoiler={MatrixClientPeg.get().getAccessToken()}>
&lt;{ _t("click to reveal") }&gt;
</AccessibleButton>
<br />
<details>
<summary>{_t("Access Token")}</summary><br />
<b>{_t("Your access token gives full access to your account."
+ " Do not share it with anyone." )}</b>
<div className="mx_HelpUserSettingsTab_accessToken">
<code>{MatrixClientPeg.get().getAccessToken()}</code>
<AccessibleTooltipButton
title={_t("Copy")}
onClick={this.onAccessTokenCopyClick}
className="mx_HelpUserSettingsTab_accessToken_copy"
/>
</div>
</details><br />
<div className='mx_HelpUserSettingsTab_debugButton'>
<AccessibleButton onClick={this._onClearCacheAndReload} kind='danger'>
<AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
{_t("Clear cache and reload")}
</AccessibleButton>
</div>

View file

@ -1,5 +1,5 @@
/*
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019-2021 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.
@ -25,10 +25,16 @@ import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import * as sdk from "../../../../../index";
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
interface IState {
busy: boolean;
newPersonalRule: string;
newList: string;
}
@replaceableComponent("views.settings.tabs.user.MjolnirUserSettingsTab")
export default class MjolnirUserSettingsTab extends React.Component {
constructor() {
super();
export default class MjolnirUserSettingsTab extends React.Component<{}, IState> {
constructor(props) {
super(props);
this.state = {
busy: false,
@ -37,15 +43,15 @@ export default class MjolnirUserSettingsTab extends React.Component {
};
}
_onPersonalRuleChanged = (e) => {
private onPersonalRuleChanged = (e) => {
this.setState({newPersonalRule: e.target.value});
};
_onNewListChanged = (e) => {
private onNewListChanged = (e) => {
this.setState({newList: e.target.value});
};
_onAddPersonalRule = async (e) => {
private onAddPersonalRule = async (e) => {
e.preventDefault();
e.stopPropagation();
@ -72,7 +78,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
}
};
_onSubscribeList = async (e) => {
private onSubscribeList = async (e) => {
e.preventDefault();
e.stopPropagation();
@ -94,7 +100,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
}
};
async _removePersonalRule(rule: ListRule) {
private async removePersonalRule(rule: ListRule) {
this.setState({busy: true});
try {
const list = Mjolnir.sharedInstance().getPersonalList();
@ -112,7 +118,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
}
}
async _unsubscribeFromList(list: BanList) {
private async unsubscribeFromList(list: BanList) {
this.setState({busy: true});
try {
await Mjolnir.sharedInstance().unsubscribeFromList(list.roomId);
@ -130,7 +136,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
}
}
_viewListRules(list: BanList) {
private viewListRules(list: BanList) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const room = MatrixClientPeg.get().getRoom(list.roomId);
@ -161,7 +167,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
});
}
_renderPersonalBanListRules() {
private renderPersonalBanListRules() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const list = Mjolnir.sharedInstance().getPersonalList();
@ -174,7 +180,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
<li key={rule.entity} className="mx_MjolnirUserSettingsTab_listItem">
<AccessibleButton
kind="danger_sm"
onClick={() => this._removePersonalRule(rule)}
onClick={() => this.removePersonalRule(rule)}
disabled={this.state.busy}
>
{_t("Remove")}
@ -192,7 +198,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
);
}
_renderSubscribedBanLists() {
private renderSubscribedBanLists() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const personalList = Mjolnir.sharedInstance().getPersonalList();
@ -209,14 +215,14 @@ export default class MjolnirUserSettingsTab extends React.Component {
<li key={list.roomId} className="mx_MjolnirUserSettingsTab_listItem">
<AccessibleButton
kind="danger_sm"
onClick={() => this._unsubscribeFromList(list)}
onClick={() => this.unsubscribeFromList(list)}
disabled={this.state.busy}
>
{_t("Unsubscribe")}
</AccessibleButton>&nbsp;
<AccessibleButton
kind="primary_sm"
onClick={() => this._viewListRules(list)}
onClick={() => this.viewListRules(list)}
disabled={this.state.busy}
>
{_t("View rules")}
@ -271,21 +277,21 @@ export default class MjolnirUserSettingsTab extends React.Component {
)}
</div>
<div>
{this._renderPersonalBanListRules()}
{this.renderPersonalBanListRules()}
</div>
<div>
<form onSubmit={this._onAddPersonalRule} autoComplete="off">
<form onSubmit={this.onAddPersonalRule} autoComplete="off">
<Field
type="text"
label={_t("Server or user ID to ignore")}
placeholder={_t("eg: @bot:* or example.org")}
value={this.state.newPersonalRule}
onChange={this._onPersonalRuleChanged}
onChange={this.onPersonalRuleChanged}
/>
<AccessibleButton
type="submit"
kind="primary"
onClick={this._onAddPersonalRule}
onClick={this.onAddPersonalRule}
disabled={this.state.busy}
>
{_t("Ignore")}
@ -303,20 +309,20 @@ export default class MjolnirUserSettingsTab extends React.Component {
)}</span>
</div>
<div>
{this._renderSubscribedBanLists()}
{this.renderSubscribedBanLists()}
</div>
<div>
<form onSubmit={this._onSubscribeList} autoComplete="off">
<form onSubmit={this.onSubscribeList} autoComplete="off">
<Field
type="text"
label={_t("Room ID or address of ban list")}
value={this.state.newList}
onChange={this._onNewListChanged}
onChange={this.onNewListChanged}
/>
<AccessibleButton
type="submit"
kind="primary"
onClick={this._onSubscribeList}
onClick={this.onSubscribeList}
disabled={this.state.busy}
>
{_t("Subscribe")}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
@ -23,10 +23,24 @@ import Field from "../../../elements/Field";
import * as sdk from "../../../../..";
import PlatformPeg from "../../../../../PlatformPeg";
import {SettingLevel} from "../../../../../settings/SettingLevel";
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
interface IState {
autoLaunch: boolean;
autoLaunchSupported: boolean;
warnBeforeExit: boolean;
warnBeforeExitSupported: boolean;
alwaysShowMenuBarSupported: boolean;
alwaysShowMenuBar: boolean;
minimizeToTraySupported: boolean;
minimizeToTray: boolean;
autocompleteDelay: string;
readMarkerInViewThresholdMs: string;
readMarkerOutOfViewThresholdMs: string;
}
@replaceableComponent("views.settings.tabs.user.PreferencesUserSettingsTab")
export default class PreferencesUserSettingsTab extends React.Component {
export default class PreferencesUserSettingsTab extends React.Component<{}, IState> {
static ROOM_LIST_SETTINGS = [
'breadcrumbs',
];
@ -68,8 +82,8 @@ export default class PreferencesUserSettingsTab extends React.Component {
// Autocomplete delay (niche text box)
];
constructor() {
super();
constructor(props) {
super(props);
this.state = {
autoLaunch: false,
@ -89,7 +103,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
};
}
async componentDidMount(): void {
async componentDidMount() {
const platform = PlatformPeg.get();
const autoLaunchSupported = await platform.supportsAutoLaunch();
@ -128,38 +142,38 @@ export default class PreferencesUserSettingsTab extends React.Component {
});
}
_onAutoLaunchChange = (checked) => {
private onAutoLaunchChange = (checked: boolean) => {
PlatformPeg.get().setAutoLaunchEnabled(checked).then(() => this.setState({autoLaunch: checked}));
};
_onWarnBeforeExitChange = (checked) => {
private onWarnBeforeExitChange = (checked: boolean) => {
PlatformPeg.get().setWarnBeforeExit(checked).then(() => this.setState({warnBeforeExit: checked}));
}
_onAlwaysShowMenuBarChange = (checked) => {
private onAlwaysShowMenuBarChange = (checked: boolean) => {
PlatformPeg.get().setAutoHideMenuBarEnabled(!checked).then(() => this.setState({alwaysShowMenuBar: checked}));
};
_onMinimizeToTrayChange = (checked) => {
private onMinimizeToTrayChange = (checked: boolean) => {
PlatformPeg.get().setMinimizeToTrayEnabled(checked).then(() => this.setState({minimizeToTray: checked}));
};
_onAutocompleteDelayChange = (e) => {
private onAutocompleteDelayChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({autocompleteDelay: e.target.value});
SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value);
};
_onReadMarkerInViewThresholdMs = (e) => {
private onReadMarkerInViewThresholdMs = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({readMarkerInViewThresholdMs: e.target.value});
SettingsStore.setValue("readMarkerInViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
};
_onReadMarkerOutOfViewThresholdMs = (e) => {
private onReadMarkerOutOfViewThresholdMs = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({readMarkerOutOfViewThresholdMs: e.target.value});
SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
};
_renderGroup(settingIds) {
private renderGroup(settingIds: string[]): React.ReactNodeArray {
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
return settingIds.filter(SettingsStore.isEnabled).map(i => {
return <SettingsFlag key={i} name={i} level={SettingLevel.ACCOUNT} />;
@ -171,7 +185,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
if (this.state.autoLaunchSupported) {
autoLaunchOption = <LabelledToggleSwitch
value={this.state.autoLaunch}
onChange={this._onAutoLaunchChange}
onChange={this.onAutoLaunchChange}
label={_t('Start automatically after system login')} />;
}
@ -179,7 +193,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
if (this.state.warnBeforeExitSupported) {
warnBeforeExitOption = <LabelledToggleSwitch
value={this.state.warnBeforeExit}
onChange={this._onWarnBeforeExitChange}
onChange={this.onWarnBeforeExitChange}
label={_t('Warn before quitting')} />;
}
@ -187,7 +201,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
if (this.state.alwaysShowMenuBarSupported) {
autoHideMenuOption = <LabelledToggleSwitch
value={this.state.alwaysShowMenuBar}
onChange={this._onAlwaysShowMenuBarChange}
onChange={this.onAlwaysShowMenuBarChange}
label={_t('Always show the window menu bar')} />;
}
@ -195,7 +209,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
if (this.state.minimizeToTraySupported) {
minimizeToTrayOption = <LabelledToggleSwitch
value={this.state.minimizeToTray}
onChange={this._onMinimizeToTrayChange}
onChange={this.onMinimizeToTrayChange}
label={_t('Show tray icon and minimize window to it on close')} />;
}
@ -205,22 +219,22 @@ export default class PreferencesUserSettingsTab extends React.Component {
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Room list")}</span>
{this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
{this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Composer")}</span>
{this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
{this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Timeline")}</span>
{this._renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
{this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("General")}</span>
{this._renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)}
{this.renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)}
{minimizeToTrayOption}
{autoHideMenuOption}
{autoLaunchOption}
@ -229,17 +243,17 @@ export default class PreferencesUserSettingsTab extends React.Component {
label={_t('Autocomplete delay (ms)')}
type='number'
value={this.state.autocompleteDelay}
onChange={this._onAutocompleteDelayChange} />
onChange={this.onAutocompleteDelayChange} />
<Field
label={_t('Read Marker lifetime (ms)')}
type='number'
value={this.state.readMarkerInViewThresholdMs}
onChange={this._onReadMarkerInViewThresholdMs} />
onChange={this.onReadMarkerInViewThresholdMs} />
<Field
label={_t('Read Marker off-screen lifetime (ms)')}
type='number'
value={this.state.readMarkerOutOfViewThresholdMs}
onChange={this._onReadMarkerOutOfViewThresholdMs} />
onChange={this.onReadMarkerOutOfViewThresholdMs} />
</div>
</div>
);

View file

@ -255,15 +255,18 @@ export default class SecurityUserSettingsTab extends React.Component {
_renderIgnoredUsers() {
const {waitingUnignored, ignoredUserIds} = this.state;
if (!ignoredUserIds || ignoredUserIds.length === 0) return null;
const userIds = ignoredUserIds
.map((u) => <IgnoredUser
userId={u}
onUnignored={this._onUserUnignored}
key={u}
inProgress={waitingUnignored.includes(u)}
/>);
const userIds = !ignoredUserIds?.length
? _t('You have no ignored users.')
: ignoredUserIds.map((u) => {
return (
<IgnoredUser
userId={u}
onUnignored={this._onUserUnignored}
key={u}
inProgress={waitingUnignored.includes(u)}
/>
);
});
return (
<div className='mx_SettingsTab_section'>

Some files were not shown because too many files have changed in this diff Show more