Merge branches 'develop' and 't3chguy/emoji_picker_composer' of github.com:matrix-org/matrix-react-sdk into t3chguy/emoji_picker_composer

 Conflicts:
	src/components/views/rooms/MessageComposer.js
This commit is contained in:
Michael Telatynski 2020-05-29 14:53:42 +01:00
commit ccd0c952e3
1131 changed files with 52941 additions and 24634 deletions

View file

@ -18,12 +18,12 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import AppTile from '../elements/AppTile';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import ScalarMessaging from '../../../ScalarMessaging';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import * as ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../utils/WidgetUtils';
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
@ -34,7 +34,7 @@ import SettingsStore from "../../../settings/SettingsStore";
// The maximum number of widgets that can be added in a room
const MAX_WIDGETS = 2;
module.exports = createReactClass({
export default createReactClass({
displayName: 'AppsDrawer',
propTypes: {
@ -55,13 +55,10 @@ module.exports = createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
ScalarMessaging.startListening();
MatrixClientPeg.get().on('RoomState.events', this.onRoomStateEvents);
WidgetEchoStore.on('update', this._updateApps);
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
},
@ -71,10 +68,11 @@ module.exports = createReactClass({
MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents);
}
WidgetEchoStore.removeListener('update', this._updateApps);
dis.unregister(this.dispatcherRef);
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
},
componentWillReceiveProps(newProps) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) {
// Room has changed probably, update apps
this._updateApps();
},
@ -83,12 +81,14 @@ module.exports = createReactClass({
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) {
case 'appsDrawer':
// Note: these booleans are awkward because localstorage is fundamentally
// string-based. We also do exact equality on the strings later on.
if (action.show) {
localStorage.removeItem(hideWidgetKey);
localStorage.setItem(hideWidgetKey, "false");
} else {
// Store hidden state of widget
// Don't show if previously hidden
localStorage.setItem(hideWidgetKey, true);
localStorage.setItem(hideWidgetKey, "true");
}
break;
@ -160,11 +160,7 @@ module.exports = createReactClass({
return (<AppTile
key={app.id}
id={app.id}
eventId={app.eventId}
url={app.url}
name={app.name}
type={app.type}
app={app}
fullWidth={arr.length<2 ? true : false}
room={this.props.room}
userId={this.props.userId}

View file

@ -17,28 +17,49 @@ limitations under the License.
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import flatMap from 'lodash/flatMap';
import type {Completion} from '../../../autocomplete/Autocompleter';
import { Room } from 'matrix-js-sdk';
import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter';
import {Room} from 'matrix-js-sdk/src/models/room';
import SettingsStore from "../../../settings/SettingsStore";
import Autocompleter from '../../../autocomplete/Autocompleter';
import {sleep} from "../../../utils/promise";
const COMPOSER_SELECTED = 0;
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
export default class Autocomplete extends React.Component {
interface IProps {
// the query string for which to show autocomplete suggestions
query: string;
// method invoked with range and text content when completion is confirmed
onConfirm: (ICompletion) => void;
// method invoked when selected (if any) completion changes
onSelectionChange?: (ICompletion, number) => void;
selection: ISelectionRange;
// The room in which we're autocompleting
room: Room;
}
interface IState {
completions: IProviderCompletions[];
completionList: ICompletion[];
selectionOffset: number;
shouldShowCompletions: boolean;
hide: boolean;
forceComplete: boolean;
}
export default class Autocomplete extends React.PureComponent<IProps, IState> {
autocompleter: Autocompleter;
queryRequested: string;
debounceCompletionsRequest: NodeJS.Timeout;
containerRef: React.RefObject<HTMLDivElement>;
constructor(props) {
super(props);
this.autocompleter = new Autocompleter(props.room);
this.completionPromise = null;
this.hide = this.hide.bind(this);
this.onCompletionClicked = this.onCompletionClicked.bind(this);
this.state = {
// list of completionResults, each containing completions
@ -57,13 +78,15 @@ export default class Autocomplete extends React.Component {
forceComplete: false,
};
this.containerRef = React.createRef();
}
componentDidMount() {
this._applyNewProps();
this.applyNewProps();
}
_applyNewProps(oldQuery, oldRoom) {
private applyNewProps(oldQuery?: string, oldRoom?: Room) {
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
this.autocompleter.destroy();
this.autocompleter = new Autocompleter(this.props.room);
@ -81,7 +104,7 @@ export default class Autocomplete extends React.Component {
this.autocompleter.destroy();
}
complete(query, selection) {
complete(query: string, selection: ISelectionRange) {
this.queryRequested = query;
if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest);
@ -112,7 +135,7 @@ export default class Autocomplete extends React.Component {
});
}
processQuery(query, selection) {
processQuery(query: string, selection: ISelectionRange) {
return this.autocompleter.getCompletions(
query, selection, this.state.forceComplete,
).then((completions) => {
@ -124,7 +147,7 @@ export default class Autocomplete extends React.Component {
});
}
processCompletions(completions) {
processCompletions(completions: IProviderCompletions[]) {
const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty.
@ -159,7 +182,7 @@ export default class Autocomplete extends React.Component {
});
}
hasSelection(): bool {
hasSelection(): boolean {
return this.countCompletions() > 0 && this.state.selectionOffset !== 0;
}
@ -168,7 +191,7 @@ export default class Autocomplete extends React.Component {
}
// called from MessageComposerInput
moveSelection(delta): ?Completion {
moveSelection(delta: number) {
const completionCount = this.countCompletions();
if (completionCount === 0) return; // there are no items to move the selection through
@ -177,7 +200,7 @@ export default class Autocomplete extends React.Component {
this.setSelection(index);
}
onEscape(e): boolean {
onEscape(e: KeyboardEvent): boolean {
const completionCount = this.countCompletions();
if (completionCount === 0) {
// autocomplete is already empty, so don't preventDefault
@ -190,9 +213,14 @@ export default class Autocomplete extends React.Component {
this.hide();
}
hide() {
this.setState({hide: true, selectionOffset: 0, completions: [], completionList: []});
}
hide = () => {
this.setState({
hide: true,
selectionOffset: 0,
completions: [],
completionList: [],
});
};
forceComplete() {
return new Promise((resolve) => {
@ -207,7 +235,7 @@ export default class Autocomplete extends React.Component {
});
}
onCompletionClicked(selectionOffset: number): boolean {
onCompletionClicked = (selectionOffset: number): boolean => {
if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) {
return false;
}
@ -216,7 +244,7 @@ export default class Autocomplete extends React.Component {
this.hide();
return true;
}
};
setSelection(selectionOffset: number) {
this.setState({selectionOffset, hide: false});
@ -225,28 +253,24 @@ export default class Autocomplete extends React.Component {
}
}
componentDidUpdate(prevProps) {
this._applyNewProps(prevProps.query, prevProps.room);
componentDidUpdate(prevProps: IProps) {
this.applyNewProps(prevProps.query, prevProps.room);
// this is the selected completion, so scroll it into view if needed
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
if (selectedCompletion && this.container) {
if (selectedCompletion && this.containerRef.current) {
const domNode = ReactDOM.findDOMNode(selectedCompletion);
const offsetTop = domNode && domNode.offsetTop;
if (offsetTop > this.container.scrollTop + this.container.offsetHeight ||
offsetTop < this.container.scrollTop) {
this.container.scrollTop = offsetTop - this.container.offsetTop;
const offsetTop = domNode && (domNode as HTMLElement).offsetTop;
if (offsetTop > this.containerRef.current.scrollTop + this.containerRef.current.offsetHeight ||
offsetTop < this.containerRef.current.scrollTop) {
this.containerRef.current.scrollTop = offsetTop - this.containerRef.current.offsetTop;
}
}
}
setState(state, func) {
super.setState(state, func);
}
render() {
let position = 1;
const renderedCompletions = this.state.completions.map((completionResult, i) => {
const completions = completionResult.completions.map((completion, i) => {
const completions = completionResult.completions.map((completion, j) => {
const selected = position === this.state.selectionOffset;
const className = classNames('mx_Autocomplete_Completion', {selected});
const componentPosition = position;
@ -257,7 +281,7 @@ export default class Autocomplete extends React.Component {
};
return React.cloneElement(completion.component, {
"key": i,
"key": j,
"ref": `completion${componentPosition}`,
"id": generateCompletionDomId(componentPosition - 1), // 0 index the completion IDs
className,
@ -276,23 +300,9 @@ export default class Autocomplete extends React.Component {
}).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}>
<div className="mx_Autocomplete" ref={this.containerRef}>
{ renderedCompletions }
</div>
) : null;
}
}
Autocomplete.propTypes = {
// the query string for which to show autocomplete suggestions
query: PropTypes.string.isRequired,
// method invoked with range and text content when completion is confirmed
onConfirm: PropTypes.func.isRequired,
// method invoked when selected (if any) completion changes
onSelectionChange: PropTypes.func,
// The room in which we're autocompleting
room: PropTypes.instanceOf(Room),
};

View file

@ -18,18 +18,19 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import MatrixClientPeg from "../../../MatrixClientPeg";
import sdk from '../../../index';
import dis from "../../../dispatcher";
import ObjectUtils from '../../../ObjectUtils';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from '../../../index';
import dis from "../../../dispatcher/dispatcher";
import * as ObjectUtils from '../../../ObjectUtils';
import AppsDrawer from './AppsDrawer';
import { _t } from '../../../languageHandler';
import classNames from 'classnames';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
module.exports = createReactClass({
export default createReactClass({
displayName: 'AuxPanel',
propTypes: {
@ -140,6 +141,15 @@ module.exports = createReactClass({
return counters;
},
_onScroll: function(rect) {
if (this.props.onResize) {
this.props.onResize();
}
/* Force refresh of PersistedElements which may be partially hidden */
window.dispatchEvent(new Event('resize'));
},
render: function() {
const CallView = sdk.getComponent("voip.CallView");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
@ -219,7 +229,7 @@ module.exports = createReactClass({
if (link) {
span = (
<a href={link} target="_blank" rel="noopener">
<a href={link} target="_blank" rel="noreferrer noopener">
{ span }
</a>
);
@ -264,14 +274,14 @@ module.exports = createReactClass({
}
return (
<div className={classes} style={style} >
<AutoHideScrollbar className={classes} style={style} onScroll={this._onScroll}>
{ stateViews }
{ appsDrawer }
{ fileDropTarget }
{ callView }
{ conferenceCallNotification }
{ this.props.children }
</div>
</AutoHideScrollbar>
);
},
});

View file

@ -34,11 +34,12 @@ import {parsePlainTextMessage} from '../../../editor/deserialize';
import {renderModel} from '../../../editor/render';
import {Room} from 'matrix-js-sdk';
import TypingStore from "../../../stores/TypingStore";
import EMOJIBASE from 'emojibase-data/en/compact.json';
import SettingsStore from "../../../settings/SettingsStore";
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import sdk from '../../../index';
import * as sdk from '../../../index';
import {Key} from "../../../Keyboard";
import {EMOTICON_TO_EMOJI} from "../../../emoji";
import {CommandCategories, CommandMap, parseCommandString} from "../../../SlashCommands";
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
@ -80,10 +81,11 @@ export default class BasicMessageEditor extends React.Component {
initialCaret: PropTypes.object, // See DocumentPosition in editor/model.js
};
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this.state = {
autoComplete: null,
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
};
this._editorRef = null;
this._autocompleteRef = null;
@ -92,6 +94,18 @@ export default class BasicMessageEditor extends React.Component {
this._isIMEComposing = false;
this._hasTextSelected = false;
this._emoticonSettingHandle = null;
this._shouldShowPillAvatarSettingHandle = null;
}
componentDidUpdate(prevProps) {
if (this.props.placeholder !== prevProps.placeholder && this.props.placeholder) {
const {isEmpty} = this.props.model;
if (isEmpty) {
this._showPlaceholder();
} else {
this._hidePlaceholder();
}
}
}
_replaceEmoticon = (caretPosition, inputType, diff) => {
@ -107,8 +121,10 @@ export default class BasicMessageEditor extends React.Component {
});
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
if (emoticonMatch) {
const query = emoticonMatch[1].toLowerCase().replace("-", "");
const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false);
const query = emoticonMatch[1].replace("-", "");
// try both exact match and lower-case, this means that xd won't match xD but :P will match :p
const data = EMOTICON_TO_EMOJI.get(query) || EMOTICON_TO_EMOJI.get(query.toLowerCase());
if (data) {
const {partCreator} = model;
const hasPrecedingSpace = emoticonMatch[0][0] === " ";
@ -136,17 +152,29 @@ export default class BasicMessageEditor extends React.Component {
const position = selection.end || selection;
this._setLastCaretFromPosition(position);
}
const {isEmpty} = this.props.model;
if (this.props.placeholder) {
const {isEmpty} = this.props.model;
if (isEmpty) {
this._showPlaceholder();
} else {
this._hidePlaceholder();
}
}
if (isEmpty) {
this._formatBarRef.hide();
}
this.setState({autoComplete: this.props.model.autoComplete});
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, !this.props.model.isEmpty);
let isTyping = !this.props.model.isEmpty;
// If the user is entering a command, only consider them typing if it is one which sends a message into the room
if (isTyping && this.props.model.parts[0].type === "command") {
const {cmd} = parseCommandString(this.props.model.parts[0].text);
if (!CommandMap.has(cmd) || CommandMap.get(cmd).category !== CommandCategories.messages) {
isTyping = false;
}
}
TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, isTyping);
if (this.props.onChange) {
this.props.onChange();
@ -186,9 +214,9 @@ export default class BasicMessageEditor extends React.Component {
if (isSafari) {
this._onInput({inputType: "insertCompositionText"});
} else {
setTimeout(() => {
Promise.resolve().then(() => {
this._onInput({inputType: "insertCompositionText"});
}, 0);
});
}
}
@ -199,17 +227,49 @@ export default class BasicMessageEditor extends React.Component {
return !!(this._isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing));
}
_onCutCopy = (event, type) => {
const selection = document.getSelection();
const text = selection.toString();
if (text) {
const {model} = this.props;
const range = getRangeForSelection(this._editorRef, model, selection);
const selectedParts = range.parts.map(p => p.serialize());
event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts));
event.clipboardData.setData("text/plain", text); // so plain copy/paste works
if (type === "cut") {
// Remove the text, updating the model as appropriate
this._modifiedFlag = true;
replaceRangeAndMoveCaret(range, []);
}
event.preventDefault();
}
}
_onCopy = (event) => {
this._onCutCopy(event, "copy");
}
_onCut = (event) => {
this._onCutCopy(event, "cut");
}
_onPaste = (event) => {
const {model} = this.props;
const {partCreator} = model;
const text = event.clipboardData.getData("text/plain");
if (text) {
this._modifiedFlag = true;
const range = getRangeForSelection(this._editorRef, model, document.getSelection());
const parts = parsePlainTextMessage(text, partCreator);
replaceRangeAndMoveCaret(range, parts);
event.preventDefault();
const partsText = event.clipboardData.getData("application/x-riot-composer");
let parts;
if (partsText) {
const serializedTextParts = JSON.parse(partsText);
const deserializedParts = serializedTextParts.map(p => partCreator.deserializePart(p));
parts = deserializedParts;
} else {
const text = event.clipboardData.getData("text/plain");
parts = parsePlainTextMessage(text, partCreator);
}
this._modifiedFlag = true;
const range = getRangeForSelection(this._editorRef, model, document.getSelection());
replaceRangeAndMoveCaret(range, parts);
event.preventDefault();
}
_onInput = (event) => {
@ -228,8 +288,8 @@ export default class BasicMessageEditor extends React.Component {
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset);
caret.offset += textToInsert.length;
this.props.model.update(newText, inputType, caret);
this._modifiedFlag = true;
this.props.model.update(newText, inputType, caret);
}
// this is used later to see if we need to recalculate the caret
@ -325,6 +385,16 @@ export default class BasicMessageEditor extends React.Component {
} else if (modKey && event.key === Key.GREATER_THAN) {
this._onFormatAction("quote");
handled = true;
// redo
} else if ((!IS_MAC && modKey && event.key === Key.Y) ||
(IS_MAC && modKey && event.shiftKey && event.key === Key.Z)) {
if (this.historyManager.canRedo()) {
const {parts, caret} = this.historyManager.redo();
// pass matching inputType so historyManager doesn't push echo
// when invoked from rerender callback.
model.reset(parts, caret, "historyRedo");
}
handled = true;
// undo
} else if (modKey && event.key === Key.Z) {
if (this.historyManager.canUndo()) {
@ -334,19 +404,24 @@ export default class BasicMessageEditor extends React.Component {
model.reset(parts, caret, "historyUndo");
}
handled = true;
// redo
} else if (modKey && event.key === Key.Y) {
if (this.historyManager.canRedo()) {
const {parts, caret} = this.historyManager.redo();
// pass matching inputType so historyManager doesn't push echo
// when invoked from rerender callback.
model.reset(parts, caret, "historyRedo");
}
handled = true;
// insert newline on Shift+Enter
} else if (event.key === Key.ENTER && (event.shiftKey || (IS_MAC && event.altKey))) {
this._insertText("\n");
handled = true;
// move selection to start of composer
} else if (modKey && event.key === Key.HOME && !event.shiftKey) {
setSelection(this._editorRef, model, {
index: 0,
offset: 0,
});
handled = true;
// move selection to end of composer
} else if (modKey && event.key === Key.END && !event.shiftKey) {
setSelection(this._editorRef, model, {
index: model.parts.length - 1,
offset: model.parts[model.parts.length - 1].text.length,
});
handled = true;
// autocomplete or enter to send below shouldn't have any modifier keys pressed.
} else {
const metaOrAltPressed = event.metaKey || event.altKey;
@ -384,6 +459,8 @@ export default class BasicMessageEditor extends React.Component {
} else if (event.key === Key.TAB) {
this._tabCompleteName();
handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
this._formatBarRef.hide();
}
}
if (handled) {
@ -412,10 +489,14 @@ export default class BasicMessageEditor extends React.Component {
const addedLen = range.replace([partCreator.pillCandidate(range.text)]);
return model.positionForOffset(caret.offset + addedLen, true);
});
await model.autoComplete.onTab();
if (!model.autoComplete.hasSelection()) {
this.setState({showVisualBell: true});
model.autoComplete.close();
// Don't try to do things with the autocomplete if there is none shown
if (model.autoComplete) {
await model.autoComplete.onTab();
if (!model.autoComplete.hasSelection()) {
this.setState({showVisualBell: true});
model.autoComplete.close();
}
}
} catch (err) {
console.error(err);
@ -439,25 +520,33 @@ export default class BasicMessageEditor extends React.Component {
this.setState({completionIndex});
}
_configureEmoticonAutoReplace() {
_configureEmoticonAutoReplace = () => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
this.props.model.setTransformCallback(shouldReplace ? this._replaceEmoticon : null);
}
};
_configureShouldShowPillAvatar = () => {
const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
this.setState({ showPillAvatar });
};
componentWillUnmount() {
document.removeEventListener("selectionchange", this._onSelectionChange);
this._editorRef.removeEventListener("input", this._onInput, true);
this._editorRef.removeEventListener("compositionstart", this._onCompositionStart, true);
this._editorRef.removeEventListener("compositionend", this._onCompositionEnd, true);
SettingsStore.unwatchSetting(this._emoticonSettingHandle);
SettingsStore.unwatchSetting(this._shouldShowPillAvatarSettingHandle);
}
componentDidMount() {
const model = this.props.model;
model.setUpdateCallback(this._updateEditorState);
this._emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null, () => {
this._configureEmoticonAutoReplace();
});
this._emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
this._configureEmoticonAutoReplace);
this._configureEmoticonAutoReplace();
this._shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
this._configureShouldShowPillAvatar);
const partCreator = model.partCreator;
// TODO: does this allow us to get rid of EditorStateTransfer?
// not really, but we could not serialize the parts, and just change the autoCompleter
@ -499,6 +588,7 @@ export default class BasicMessageEditor extends React.Component {
return;
}
this.historyManager.ensureLastChangesPushed(this.props.model);
this._modifiedFlag = true;
switch (action) {
case "bold":
toggleInlineFormat(range, "**");
@ -534,9 +624,12 @@ export default class BasicMessageEditor extends React.Component {
/>
</div>);
}
const classes = classNames("mx_BasicMessageComposer", {
const wrapperClasses = classNames("mx_BasicMessageComposer", {
"mx_BasicMessageComposer_input_error": this.state.showVisualBell,
});
const classes = classNames("mx_BasicMessageComposer_input", {
"mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar,
});
const MessageComposerFormatBar = sdk.getComponent('rooms.MessageComposerFormatBar');
const shortcuts = {
@ -547,15 +640,17 @@ export default class BasicMessageEditor extends React.Component {
const {completionIndex} = this.state;
return (<div className={classes}>
return (<div className={wrapperClasses}>
{ autoComplete }
<MessageComposerFormatBar ref={ref => this._formatBarRef = ref} onAction={this._onFormatAction} shortcuts={shortcuts} />
<div
className="mx_BasicMessageComposer_input"
className={classes}
contentEditable="true"
tabIndex="0"
onBlur={this._onBlur}
onFocus={this._onFocus}
onCopy={this._onCopy}
onCut={this._onCut}
onPaste={this._onPaste}
onKeyDown={this._onKeyDown}
ref={ref => this._editorRef = ref}

View file

@ -1,5 +1,6 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,76 +15,102 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from "react";
import PropTypes from "prop-types";
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import SettingsStore from '../../../settings/SettingsStore';
export default function(props) {
const { isUser } = props;
const isNormal = props.status === "normal";
const isWarning = props.status === "warning";
const isVerified = props.status === "verified";
const e2eIconClasses = classNames({
import {_t, _td} from '../../../languageHandler';
import {useSettingValue} from "../../../hooks/useSettings";
import AccessibleButton from "../elements/AccessibleButton";
import Tooltip from "../elements/Tooltip";
export const E2E_STATE = {
VERIFIED: "verified",
WARNING: "warning",
UNKNOWN: "unknown",
NORMAL: "normal",
};
const crossSigningUserTitles = {
[E2E_STATE.WARNING]: _td("This user has not verified all of their sessions."),
[E2E_STATE.NORMAL]: _td("You have not verified this user."),
[E2E_STATE.VERIFIED]: _td("You have verified this user. This user has verified all of their sessions."),
};
const crossSigningRoomTitles = {
[E2E_STATE.WARNING]: _td("Someone is using an unknown session"),
[E2E_STATE.NORMAL]: _td("This room is end-to-end encrypted"),
[E2E_STATE.VERIFIED]: _td("Everyone in this room is verified"),
};
const legacyUserTitles = {
[E2E_STATE.WARNING]: _td("Some sessions for this user are not trusted"),
[E2E_STATE.VERIFIED]: _td("All sessions for this user are trusted"),
};
const legacyRoomTitles = {
[E2E_STATE.WARNING]: _td("Some sessions in this encrypted room are not trusted"),
[E2E_STATE.VERIFIED]: _td("All sessions in this encrypted room are trusted"),
};
const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip}) => {
const [hover, setHover] = useState(false);
const classes = classNames({
mx_E2EIcon: true,
mx_E2EIcon_warning: isWarning,
mx_E2EIcon_normal: isNormal,
mx_E2EIcon_verified: isVerified,
}, props.className);
mx_E2EIcon_warning: status === E2E_STATE.WARNING,
mx_E2EIcon_normal: status === E2E_STATE.NORMAL,
mx_E2EIcon_verified: status === E2E_STATE.VERIFIED,
}, className);
let e2eTitle;
const crossSigning = SettingsStore.isFeatureEnabled("feature_cross_signing");
const crossSigning = useSettingValue("feature_cross_signing");
if (crossSigning && isUser) {
if (isWarning) {
e2eTitle = _t(
"This user has not verified all of their devices.",
);
} else if (isNormal) {
e2eTitle = _t(
"You have not verified this user. " +
"This user has verified all of their devices.",
);
} else if (isVerified) {
e2eTitle = _t(
"You have verified this user. " +
"This user has verified all of their devices.",
);
}
e2eTitle = crossSigningUserTitles[status];
} else if (crossSigning && !isUser) {
if (isWarning) {
e2eTitle = _t(
"Some users in this encrypted room are not verified by you or " +
"they have not verified their own devices.",
);
} else if (isVerified) {
e2eTitle = _t(
"All users in this encrypted room are verified by you and " +
"they have verified their own devices.",
);
}
e2eTitle = crossSigningRoomTitles[status];
} else if (!crossSigning && isUser) {
if (isWarning) {
e2eTitle = _t("Some devices for this user are not trusted");
} else if (isVerified) {
e2eTitle = _t("All devices for this user are trusted");
}
e2eTitle = legacyUserTitles[status];
} else if (!crossSigning && !isUser) {
if (isWarning) {
e2eTitle = _t("Some devices in this encrypted room are not trusted");
} else if (isVerified) {
e2eTitle = _t("All devices in this encrypted room are trusted");
}
e2eTitle = legacyRoomTitles[status];
}
let style = null;
if (props.size) {
style = {width: `${props.size}px`, height: `${props.size}px`};
let style;
if (size) {
style = {width: `${size}px`, height: `${size}px`};
}
const icon = (<div className={e2eIconClasses} style={style} title={e2eTitle} />);
if (props.onClick) {
return (<AccessibleButton onClick={props.onClick}>{ icon }</AccessibleButton>);
} else {
return icon;
const onMouseOver = () => setHover(true);
const onMouseOut = () => setHover(false);
let tip;
if (hover && !hideTooltip) {
tip = <Tooltip label={e2eTitle ? _t(e2eTitle) : ""} />;
}
}
if (onClick) {
return (
<AccessibleButton
onClick={onClick}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
className={classes}
style={style}
>
{ tip }
</AccessibleButton>
);
}
return <div onMouseOver={onMouseOver} onMouseOut={onMouseOut} className={classes} style={style}>
{ tip }
</div>;
};
E2EIcon.propTypes = {
isUser: PropTypes.bool,
status: PropTypes.oneOf(Object.values(E2E_STATE)),
className: PropTypes.string,
size: PropTypes.number,
onClick: PropTypes.func,
};
export default E2EIcon;

View file

@ -15,10 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import * as sdk from '../../../index';
import {_t} from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import EditorModel from '../../../editor/model';
import {getCaretOffsetAndText} from '../../../editor/dom';
import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize';
@ -26,11 +26,11 @@ import {findEditableEvent} from '../../../utils/EventUtils';
import {parseEvent} from '../../../editor/deserialize';
import {PartCreator} from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import {MatrixClient} from 'matrix-js-sdk';
import classNames from 'classnames';
import {EventStatus} from 'matrix-js-sdk';
import BasicMessageComposer from "./BasicMessageComposer";
import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
function _isReply(mxEvent) {
const relatesTo = mxEvent.getContent()["m.relates_to"];
@ -105,9 +105,7 @@ export default class EditMessageComposer extends React.Component {
editState: PropTypes.instanceOf(EditorStateTransfer).isRequired,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
};
static contextType = MatrixClientContext;
constructor(props, context) {
super(props, context);
@ -117,6 +115,7 @@ export default class EditMessageComposer extends React.Component {
this.state = {
saveDisabled: true,
};
this._createEditorModel();
}
_setEditorRef = ref => {
@ -124,7 +123,7 @@ export default class EditMessageComposer extends React.Component {
};
_getRoom() {
return this.context.matrixClient.getRoom(this.props.editState.getEvent().getRoomId());
return this.context.getRoom(this.props.editState.getEvent().getRoomId());
}
_onKeyDown = (event) => {
@ -190,7 +189,8 @@ export default class EditMessageComposer extends React.Component {
if (this._isContentModified(newContent)) {
const roomId = editedEvent.getRoomId();
this._cancelPreviousPendingEdit();
this.context.matrixClient.sendMessage(roomId, editContent);
this.context.sendMessage(roomId, editContent);
dis.dispatch({action: "message_sent"});
}
// close the event editing and focus composer
@ -205,7 +205,7 @@ export default class EditMessageComposer extends React.Component {
previousEdit.status === EventStatus.QUEUED ||
previousEdit.status === EventStatus.NOT_SENT
)) {
this.context.matrixClient.cancelPendingEvent(previousEdit);
this.context.cancelPendingEvent(previousEdit);
}
}
@ -225,14 +225,10 @@ export default class EditMessageComposer extends React.Component {
this.props.editState.setEditorState(caret, parts);
}
componentWillMount() {
this._createEditorModel();
}
_createEditorModel() {
const {editState} = this.props;
const room = this._getRoom();
const partCreator = new PartCreator(room, this.context.matrixClient);
const partCreator = new PartCreator(room, this.context);
let parts;
if (editState.hasEditorState()) {
// if restoring state from a previous editor,

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,11 +19,11 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import * as sdk from '../../../index';
import AccessibleButton from '../elements/AccessibleButton';
import { _t } from '../../../languageHandler';
import classNames from "classnames";
import E2EIcon from './E2EIcon';
const PRESENCE_CLASS = {
"offline": "mx_EntityTile_offline",
@ -30,7 +31,6 @@ const PRESENCE_CLASS = {
"unavailable": "mx_EntityTile_unavailable",
};
function presenceClassForMember(presenceState, lastActiveAgo, showPresence) {
if (showPresence === false) {
return 'mx_EntityTile_online_beenactive';
@ -69,6 +69,7 @@ const EntityTile = createReactClass({
suppressOnHover: PropTypes.bool,
showPresence: PropTypes.bool,
subtextLabel: PropTypes.string,
e2eStatus: PropTypes.string,
},
getDefaultProps: function() {
@ -156,23 +157,26 @@ const EntityTile = createReactClass({
);
}
let power;
let powerLabel;
const powerStatus = this.props.powerStatus;
if (powerStatus) {
const src = {
[EntityTile.POWER_STATUS_MODERATOR]: require("../../../../res/img/mod.svg"),
[EntityTile.POWER_STATUS_ADMIN]: require("../../../../res/img/admin.svg"),
}[powerStatus];
const alt = {
[EntityTile.POWER_STATUS_MODERATOR]: _t("Moderator"),
const powerText = {
[EntityTile.POWER_STATUS_MODERATOR]: _t("Mod"),
[EntityTile.POWER_STATUS_ADMIN]: _t("Admin"),
}[powerStatus];
power = <img src={src} className="mx_EntityTile_power" width="16" height="17" alt={alt} />;
powerLabel = <div className="mx_EntityTile_power">{powerText}</div>;
}
let e2eIcon;
const { e2eStatus } = this.props;
if (e2eStatus) {
e2eIcon = <E2EIcon status={e2eStatus} isUser={true} />;
}
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const av = this.props.avatarJsx || <BaseAvatar name={this.props.name} width={36} height={36} />;
const av = this.props.avatarJsx ||
<BaseAvatar name={this.props.name} width={36} height={36} aria-hidden="true" />;
// The wrapping div is required to make the magic mouse listener work, for some reason.
return (
@ -181,9 +185,10 @@ const EntityTile = createReactClass({
onClick={this.props.onClick}>
<div className="mx_EntityTile_avatar">
{ av }
{ power }
{ e2eIcon }
</div>
{ nameEl }
{ powerLabel }
{ inviteButton }
</AccessibleButton>
</div>
@ -194,5 +199,4 @@ const EntityTile = createReactClass({
EntityTile.POWER_STATUS_MODERATOR = "moderator";
EntityTile.POWER_STATUS_ADMIN = "admin";
export default EntityTile;

View file

@ -1,8 +1,8 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,39 +18,37 @@ limitations under the License.
*/
import ReplyThread from "../elements/ReplyThread";
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
const classNames = require("classnames");
import classNames from "classnames";
import { _t, _td } from '../../../languageHandler';
const Modal = require('../../../Modal');
const sdk = require('../../../index');
const TextForEvent = require('../../../TextForEvent');
import dis from '../../../dispatcher';
import * as TextForEvent from "../../../TextForEvent";
import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import {EventStatus, MatrixClient} from 'matrix-js-sdk';
import {EventStatus} from 'matrix-js-sdk';
import {formatTime} from "../../../DateUtils";
import MatrixClientPeg from '../../../MatrixClientPeg';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
const ObjectUtils = require('../../../ObjectUtils');
import * as ObjectUtils from "../../../ObjectUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon";
import {toRem} from "../../../utils/units";
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
'm.sticker': 'messages.MessageEvent',
'm.key.verification.cancel': 'messages.MKeyVerificationConclusion',
'm.key.verification.done': 'messages.MKeyVerificationConclusion',
'm.room.encryption': 'messages.EncryptionEvent',
'm.call.invite': 'messages.TextualEvent',
'm.call.answer': 'messages.TextualEvent',
'm.call.hangup': 'messages.TextualEvent',
};
const stateEventTileTypes = {
'm.room.aliases': 'messages.TextualEvent',
// 'm.room.aliases': 'messages.RoomAliasesEvent', // too complex
'm.room.encryption': 'messages.EncryptionEvent',
'm.room.canonical_alias': 'messages.TextualEvent',
'm.room.create': 'messages.RoomCreate',
'm.room.member': 'messages.TextualEvent',
@ -58,11 +56,11 @@ const stateEventTileTypes = {
'm.room.avatar': 'messages.RoomAvatarEvent',
'm.room.third_party_invite': 'messages.TextualEvent',
'm.room.history_visibility': 'messages.TextualEvent',
'm.room.encryption': 'messages.TextualEvent',
'm.room.topic': 'messages.TextualEvent',
'm.room.power_levels': 'messages.TextualEvent',
'm.room.pinned_events': 'messages.TextualEvent',
'm.room.server_acl': 'messages.TextualEvent',
// TODO: Enable support for m.widget event type (https://github.com/vector-im/riot-web/issues/13111)
'im.vector.modular.widgets': 'messages.TextualEvent',
'm.room.tombstone': 'messages.TextualEvent',
'm.room.join_rules': 'messages.TextualEvent',
@ -75,7 +73,7 @@ for (const evType of ALL_RULE_TYPES) {
stateEventTileTypes[evType] = 'messages.TextualEvent';
}
function getHandlerTile(ev) {
export function getHandlerTile(ev) {
const type = ev.getType();
// don't show verification requests we're not involved in,
@ -102,6 +100,17 @@ function getHandlerTile(ev) {
}
}
// sometimes MKeyVerificationConclusion declines to render. Jankily decline to render and
// fall back to showing hidden events, if we're viewing hidden events
// XXX: This is extremely a hack. Possibly these components should have an interface for
// declining to render?
if (type === "m.key.verification.cancel" && SettingsStore.getValue("showHiddenEventsInTimeline")) {
const MKeyVerificationConclusion = sdk.getComponent("messages.MKeyVerificationConclusion");
if (!MKeyVerificationConclusion.prototype._shouldRender.call(null, ev, ev.request)) {
return;
}
}
return ev.isState() ? stateEventTileTypes[type] : eventTileTypes[type];
}
@ -118,7 +127,7 @@ const MAX_READ_AVATARS = 5;
// | '--------------------------------------' |
// '----------------------------------------------------------'
module.exports = createReactClass({
export default createReactClass({
displayName: 'EventTile',
propTypes: {
@ -197,6 +206,9 @@ module.exports = createReactClass({
// whether to show reactions for this event
showReactions: PropTypes.bool,
// whether to use the irc layout
useIRCLayout: PropTypes.bool,
},
getDefaultProps: function() {
@ -222,11 +234,12 @@ module.exports = createReactClass({
};
},
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
statics: {
contextType: MatrixClientContext,
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
// don't do RR animations until we are mounted
this._suppressReadReceiptAnimation = true;
this._verifyEvent(this.props.mxEvent);
@ -237,15 +250,17 @@ module.exports = createReactClass({
componentDidMount: function() {
this._suppressReadReceiptAnimation = false;
const client = this.context.matrixClient;
const client = this.context;
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
}
},
componentWillReceiveProps: function(nextProps) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(nextProps) {
// re-check the sender verification as outgoing events progress through
// the send process.
if (nextProps.eventSendStatus !== this.props.eventSendStatus) {
@ -262,8 +277,9 @@ module.exports = createReactClass({
},
componentWillUnmount: function() {
const client = this.context.matrixClient;
const client = this.context;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
@ -286,18 +302,56 @@ module.exports = createReactClass({
}
},
onUserVerificationChanged: function(userId, _trustStatus) {
if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent);
}
},
_verifyEvent: async function(mxEvent) {
if (!mxEvent.isEncrypted()) {
return;
}
const verified = await this.context.matrixClient.isEventSenderVerified(mxEvent);
// If we directly trust the device, short-circuit here
const verified = await this.context.isEventSenderVerified(mxEvent);
if (verified) {
this.setState({
verified: E2E_STATE.VERIFIED,
}, () => {
// Decryption may have caused a change in size
this.props.onHeightChanged();
});
return;
}
// If cross-signing is off, the old behaviour is to scream at the user
// as if they've done something wrong, which they haven't
if (!SettingsStore.getValue("feature_cross_signing")) {
this.setState({
verified: E2E_STATE.WARNING,
}, this.props.onHeightChanged);
return;
}
if (!this.context.checkUserTrust(mxEvent.getSender()).isCrossSigningVerified()) {
this.setState({
verified: E2E_STATE.NORMAL,
}, this.props.onHeightChanged);
return;
}
const eventSenderTrust = await this.context.checkEventSenderTrust(mxEvent);
if (!eventSenderTrust) {
this.setState({
verified: E2E_STATE.UNKNOWN,
}, this.props.onHeightChanged); // Decryption may have cause a change in size
return;
}
this.setState({
verified: verified,
}, () => {
// Decryption may have caused a change in size
this.props.onHeightChanged();
});
verified: eventSenderTrust.isVerified() ? E2E_STATE.VERIFIED : E2E_STATE.WARNING,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
},
_propsEqual: function(objA, objB) {
@ -349,11 +403,11 @@ module.exports = createReactClass({
},
shouldHighlight: function() {
const actions = this.context.matrixClient.getPushActionsForEvent(this.props.mxEvent);
const actions = this.context.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent);
if (!actions || !actions.tweaks) { return false; }
// don't show self-highlights from another of our clients
if (this.props.mxEvent.getSender() === this.context.matrixClient.credentials.userId) {
if (this.props.mxEvent.getSender() === this.context.credentials.userId) {
return false;
}
@ -423,7 +477,7 @@ module.exports = createReactClass({
if (remainder > 0) {
remText = <span className="mx_EventTile_readAvatarRemainder"
onClick={this.toggleAllReadAvatars}
style={{ right: -(left - receiptOffset) }}>{ remainder }+
style={{ right: "calc(" + toRem(-left) + " + " + receiptOffset + "px)" }}>{ remainder }+
</span>;
}
}
@ -442,15 +496,6 @@ module.exports = createReactClass({
});
},
onCryptoClick: function(e) {
const event = this.props.mxEvent;
Modal.createTrackedDialogAsync('Encrypted Event Dialog', '',
import('../../../async-components/views/dialogs/EncryptedEventDialog'),
{event},
);
},
onRequestKeysClick: function() {
this.setState({
// Indicate in the UI that the keys have been requested (this is expected to
@ -461,7 +506,7 @@ module.exports = createReactClass({
// Cancel any outgoing key request for this event and resend it. If a response
// is received for the request with the required keys, the event could be
// decrypted successfully.
this.context.matrixClient.cancelAndResendEventRoomKeyRequest(this.props.mxEvent);
this.context.cancelAndResendEventRoomKeyRequest(this.props.mxEvent);
},
onPermalinkClicked: function(e) {
@ -478,23 +523,26 @@ module.exports = createReactClass({
_renderE2EPadlock: function() {
const ev = this.props.mxEvent;
const props = {onClick: this.onCryptoClick};
// event could not be decrypted
if (ev.getContent().msgtype === 'm.bad.encrypted') {
return <E2ePadlockUndecryptable {...props} />;
return <E2ePadlockUndecryptable />;
}
// event is encrypted, display padlock corresponding to whether or not it is verified
if (ev.isEncrypted()) {
if (this.state.verified) {
if (this.state.verified === E2E_STATE.NORMAL) {
return; // no icon if we've not even cross-signed the user
} else if (this.state.verified === E2E_STATE.VERIFIED) {
return; // no icon for verified
} else if (this.state.verified === E2E_STATE.UNKNOWN) {
return (<E2ePadlockUnknown />);
} else {
return (<E2ePadlockUnverified {...props} />);
return (<E2ePadlockUnverified />);
}
}
if (this.context.matrixClient.isRoomEncrypted(ev.getRoomId())) {
if (this.context.isRoomEncrypted(ev.getRoomId())) {
// else if room is encrypted
// and event is being encrypted or is not_sent (Unknown Devices/Network Error)
if (ev.status === EventStatus.ENCRYPTING) {
@ -507,7 +555,7 @@ module.exports = createReactClass({
return; // we expect this to be unencrypted
}
// if the event is not encrypted, but it's an e2e room, show the open padlock
return <E2ePadlockUnencrypted {...props} />;
return <E2ePadlockUnencrypted />;
}
// no padlock needed
@ -541,6 +589,7 @@ module.exports = createReactClass({
console.error("EventTile attempted to get relations for an event without an ID");
// Use event's special `toJSON` method to log key data.
console.log(JSON.stringify(this.props.mxEvent, null, 4));
console.trace("Stacktrace for https://github.com/vector-im/riot-web/issues/11120");
}
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
},
@ -568,7 +617,8 @@ module.exports = createReactClass({
// Info messages are basically information about commands processed on a room
const isBubbleMessage = eventType.startsWith("m.key.verification") ||
(eventType === "m.room.message" && msgtype && msgtype.startsWith("m.key.verification"));
(eventType === "m.room.message" && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === "m.room.encryption");
let isInfoMessage = (
!isBubbleMessage && eventType !== 'm.room.message' &&
eventType !== 'm.sticker' && eventType !== 'm.room.create'
@ -618,11 +668,11 @@ module.exports = createReactClass({
mx_EventTile_last: this.props.last,
mx_EventTile_contextual: this.props.contextual,
mx_EventTile_actionBarFocused: this.state.actionBarFocused,
mx_EventTile_verified: !isBubbleMessage && this.state.verified === true,
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === false,
mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2E_STATE.VERIFIED,
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2E_STATE.WARNING,
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN,
mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_redacted: isRedacted,
});
let permalink = "#";
@ -648,6 +698,9 @@ module.exports = createReactClass({
// joins/parts/etc
avatarSize = 14;
needsSenderProfile = false;
} else if (this.props.useIRCLayout) {
avatarSize = 14;
needsSenderProfile = true;
} else if (this.props.continuation && this.props.tileShape !== "file_grid") {
// no avatar or sender profile for continuation messages
avatarSize = 0;
@ -700,15 +753,15 @@ module.exports = createReactClass({
<div className="mx_EventTile_keyRequestInfo_tooltip_contents">
<p>
{ this.state.previouslyRequestedKeys ?
_t( 'Your key share request has been sent - please check your other devices ' +
_t( 'Your key share request has been sent - please check your other sessions ' +
'for key share requests.') :
_t( 'Key share requests are sent to your other devices automatically. If you ' +
'rejected or dismissed the key share request on your other devices, click ' +
_t( 'Key share requests are sent to your other sessions automatically. If you ' +
'rejected or dismissed the key share request on your other sessions, click ' +
'here to request the keys for this session again.')
}
</p>
<p>
{ _t( 'If your other devices do not have the key for this message you will not ' +
{ _t( 'If your other sessions do not have the key for this message you will not ' +
'be able to decrypt them.')
}
</p>
@ -716,7 +769,7 @@ module.exports = createReactClass({
const keyRequestInfoContent = this.state.previouslyRequestedKeys ?
_t('Key request sent.') :
_t(
'<requestLink>Re-request encryption keys</requestLink> from your other devices.',
'<requestLink>Re-request encryption keys</requestLink> from your other sessions.',
{},
{'requestLink': (sub) => <a onClick={this.onRequestKeysClick}>{ sub }</a>},
);
@ -739,9 +792,22 @@ module.exports = createReactClass({
/>;
}
const linkedTimestamp = <a
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
>
{ timestamp }
</a>;
const groupTimestamp = !this.props.useIRCLayout ? linkedTimestamp : null;
const ircTimestamp = this.props.useIRCLayout ? linkedTimestamp : null;
const groupPadlock = !this.props.useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const ircPadlock = this.props.useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
switch (this.props.tileShape) {
case 'notif': {
const room = this.context.matrixClient.getRoom(this.props.mxEvent.getRoomId());
const room = this.context.getRoom(this.props.mxEvent.getRoomId());
return (
<div className={classes}>
<div className="mx_EventTile_roomName">
@ -806,13 +872,13 @@ module.exports = createReactClass({
}
return (
<div className={classes}>
{ ircTimestamp }
{ avatar }
{ sender }
{ ircPadlock }
<div className="mx_EventTile_reply">
<a href={permalink} onClick={this.onPermalinkClicked}>
{ timestamp }
</a>
{ !isBubbleMessage && this._renderE2EPadlock() }
{ groupTimestamp }
{ groupPadlock }
{ thread }
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
@ -830,23 +896,21 @@ module.exports = createReactClass({
this.props.onHeightChanged,
this.props.permalinkCreator,
this._replyThread,
this.props.useIRCLayout,
);
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return (
<div className={classes} tabIndex={-1}>
{ ircTimestamp }
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{ sender }
{ ircPadlock }
<div className="mx_EventTile_line">
<a
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
>
{ timestamp }
</a>
{ !isBubbleMessage && this._renderE2EPadlock() }
{ groupTimestamp }
{ groupPadlock }
{ thread }
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
@ -879,7 +943,7 @@ function isMessageEvent(ev) {
return (messageTypes.includes(ev.getType()));
}
module.exports.haveTileForEvent = function(e) {
export function haveTileForEvent(e) {
// Only messages have a tile (black-rectangle) if redacted
if (e.isRedacted() && !isMessageEvent(e)) return false;
@ -895,7 +959,7 @@ module.exports.haveTileForEvent = function(e) {
} else {
return true;
}
};
}
function E2ePadlockUndecryptable(props) {
return (
@ -905,7 +969,7 @@ function E2ePadlockUndecryptable(props) {
function E2ePadlockUnverified(props) {
return (
<E2ePadlock title={_t("Encrypted by an unverified device")} icon="unverified" {...props} />
<E2ePadlock title={_t("Encrypted by an unverified session")} icon="unverified" {...props} />
);
}
@ -915,11 +979,16 @@ function E2ePadlockUnencrypted(props) {
);
}
function E2ePadlockUnknown(props) {
return (
<E2ePadlock title={_t("Encrypted by a deleted session")} icon="unknown" {...props} />
);
}
class E2ePadlock extends React.Component {
static propTypes = {
icon: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
onClick: PropTypes.func,
};
constructor() {
@ -930,10 +999,6 @@ class E2ePadlock extends React.Component {
};
}
onClick = (e) => {
if (this.props.onClick) this.props.onClick(e);
};
onHoverStart = () => {
this.setState({hover: true});
};
@ -964,5 +1029,3 @@ class E2ePadlock extends React.Component {
);
}
}
module.exports.getHandlerTile = getHandlerTile;

View file

@ -19,33 +19,29 @@ import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import {Key} from '../../../Keyboard';
module.exports = createReactClass({
export default createReactClass({
displayName: 'ForwardMessage',
propTypes: {
onCancelClick: PropTypes.func.isRequired,
},
componentWillMount: function() {
componentDidMount: function() {
dis.dispatch({
action: 'panel_disable',
rightDisabled: true,
middleDisabled: true,
});
},
componentDidMount: function() {
document.addEventListener('keydown', this._onKeyDown);
},
componentWillUnmount: function() {
dis.dispatch({
action: 'panel_disable',
sideDisabled: false,
middleDisabled: false,
});
document.removeEventListener('keydown', this._onKeyDown);

View file

@ -0,0 +1,53 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
export default class InviteOnlyIcon extends React.Component {
constructor() {
super();
this.state = {
hover: false,
};
}
onHoverStart = () => {
this.setState({hover: true});
};
onHoverEnd = () => {
this.setState({hover: false});
};
render() {
const classes = this.props.collapsedPanel ? "mx_InviteOnlyIcon_small": "mx_InviteOnlyIcon_large";
const Tooltip = sdk.getComponent("elements.Tooltip");
let tooltip;
if (this.state.hover) {
tooltip = <Tooltip className="mx_InviteOnlyIcon_tooltip" label={_t("Invite only")} dir="auto" />;
}
return (<div className={classes}
onMouseEnter={this.onHoverStart}
onMouseLeave={this.onHoverEnd}
>
{ tooltip }
</div>);
}
}

View file

@ -24,7 +24,7 @@ export default (props) => {
}
return (<div className="mx_JumpToBottomButton">
<AccessibleButton className="mx_JumpToBottomButton_scrollDown"
title={_t("Scroll to bottom of page")}
title={_t("Scroll to most recent messages")}
onClick={props.onScrollToBottomClick}>
</AccessibleButton>
{ badge }

View file

@ -18,16 +18,16 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { linkifyElement } from '../../../HtmlUtils';
import { AllHtmlEntities } from 'html-entities';
import {linkifyElement} from '../../../HtmlUtils';
import SettingsStore from "../../../settings/SettingsStore";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils";
import { _t } from "../../../languageHandler";
const sdk = require('../../../index');
const MatrixClientPeg = require('../../../MatrixClientPeg');
const ImageUtils = require('../../../ImageUtils');
const Modal = require('../../../Modal');
module.exports = createReactClass({
export default createReactClass({
displayName: 'LinkPreviewWidget',
propTypes: {
@ -43,7 +43,8 @@ module.exports = createReactClass({
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this.unmounted = false;
MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((res)=>{
if (this.unmounted) {
@ -127,15 +128,19 @@ module.exports = createReactClass({
</div>;
}
// The description includes &-encoded HTML entities, we decode those as React treats the thing as an
// opaque string. This does not allow any HTML to be injected into the DOM.
const description = AllHtmlEntities.decode(p["og:description"] || "");
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_LinkPreviewWidget" >
{ img }
<div className="mx_LinkPreviewWidget_caption">
<div className="mx_LinkPreviewWidget_title"><a href={this.props.link} target="_blank" rel="noopener">{ p["og:title"] }</a></div>
<div className="mx_LinkPreviewWidget_title"><a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a></div>
<div className="mx_LinkPreviewWidget_siteName">{ p["og:site_name"] ? (" - " + p["og:site_name"]) : null }</div>
<div className="mx_LinkPreviewWidget_description" ref={this._description}>
{ p["og:description"] }
{ description }
</div>
</div>
<AccessibleButton className="mx_LinkPreviewWidget_cancel" onClick={this.props.onCancelClick} aria-label={_t("Close preview")}>

View file

@ -16,13 +16,15 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import classNames from 'classnames';
export default class MemberDeviceInfo extends React.Component {
render() {
const DeviceVerifyButtons = sdk.getComponent('elements.DeviceVerifyButtons');
// XXX: These checks are not cross-signing aware but this component is only used
// from the old, pre-cross-signing memberinfopanel
const iconClasses = classNames({
mx_MemberDeviceInfo_icon: true,
mx_MemberDeviceInfo_icon_blacklisted: this.props.device.isBlocked(),

View file

@ -31,14 +31,13 @@ import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import classNames from 'classnames';
import { MatrixClient } from 'matrix-js-sdk';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import Modal from '../../../Modal';
import sdk from '../../../index';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import createRoom from '../../../createRoom';
import DMRoomMap from '../../../utils/DMRoomMap';
import Unread from '../../../Unread';
import * as Unread from '../../../Unread';
import { findReadReceiptFromUserId } from '../../../utils/Receipt';
import AccessibleButton from '../elements/AccessibleButton';
import RoomViewStore from '../../../stores/RoomViewStore';
@ -47,10 +46,11 @@ import MultiInviter from "../../../utils/MultiInviter";
import SettingsStore from "../../../settings/SettingsStore";
import E2EIcon from "./E2EIcon";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import MatrixClientPeg from "../../../MatrixClientPeg";
import {EventTimeline} from "matrix-js-sdk";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions";
module.exports = createReactClass({
export default createReactClass({
displayName: 'MemberInfo',
propTypes: {
@ -76,13 +76,14 @@ module.exports = createReactClass({
};
},
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
statics: {
contextType: MatrixClientContext,
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount: function() {
this._cancelDeviceList = null;
const cli = this.context.matrixClient;
const cli = this.context;
// only display the devices list if our client supports E2E
this._enableDevices = cli.isCryptoEnabled();
@ -99,20 +100,19 @@ module.exports = createReactClass({
cli.on("accountData", this.onAccountData);
this._checkIgnoreState();
},
componentDidMount: function() {
this._updateStateForNewMember(this.props.member);
},
componentWillReceiveProps: function(newProps) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(newProps) {
if (this.props.member.userId !== newProps.member.userId) {
this._updateStateForNewMember(newProps.member);
}
},
componentWillUnmount: function() {
const client = this.context.matrixClient;
const client = this.context;
if (client) {
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.removeListener("Room", this.onRoom);
@ -131,7 +131,7 @@ module.exports = createReactClass({
},
_checkIgnoreState: function() {
const isIgnoring = this.context.matrixClient.isUserIgnored(this.props.member.userId);
const isIgnoring = this.context.isUserIgnored(this.props.member.userId);
this.setState({isIgnoring: isIgnoring});
},
@ -161,13 +161,10 @@ module.exports = createReactClass({
// no need to re-download the whole thing; just update our copy of
// the list.
// Promise.resolve to handle transition from static result to promise; can be removed
// in future
Promise.resolve(this.context.matrixClient.getStoredDevicesForUser(userId)).then((devices) => {
this.setState({
devices: devices,
e2eStatus: this._getE2EStatus(devices),
});
const devices = this.context.getStoredDevicesForUser(userId);
this.setState({
devices: devices,
e2eStatus: this._getE2EStatus(devices),
});
}
},
@ -197,7 +194,7 @@ module.exports = createReactClass({
onRoomReceipt: function(receiptEvent, room) {
// because if we read a notification, it will affect notification count
// only bother updating if there's a receipt from us
if (findReadReceiptFromUserId(receiptEvent, this.context.matrixClient.credentials.userId)) {
if (findReadReceiptFromUserId(receiptEvent, this.context.credentials.userId)) {
this.forceUpdate();
}
},
@ -242,7 +239,7 @@ module.exports = createReactClass({
let cancelled = false;
this._cancelDeviceList = function() { cancelled = true; };
const client = this.context.matrixClient;
const client = this.context;
const self = this;
client.downloadKeys([member.userId], true).then(() => {
return client.getStoredDevicesForUser(member.userId);
@ -261,13 +258,13 @@ module.exports = createReactClass({
e2eStatus: self._getE2EStatus(devices),
});
}, function(err) {
console.log("Error downloading devices", err);
console.log("Error downloading sessions", err);
self.setState({devicesLoading: false});
});
},
onIgnoreToggle: function() {
const ignoredUsers = this.context.matrixClient.getIgnoredUsers();
const ignoredUsers = this.context.getIgnoredUsers();
if (this.state.isIgnoring) {
const index = ignoredUsers.indexOf(this.props.member.userId);
if (index !== -1) ignoredUsers.splice(index, 1);
@ -275,7 +272,7 @@ module.exports = createReactClass({
ignoredUsers.push(this.props.member.userId);
}
this.context.matrixClient.setIgnoredUsers(ignoredUsers).then(() => {
this.context.setIgnoredUsers(ignoredUsers).then(() => {
return this.setState({isIgnoring: !this.state.isIgnoring});
});
},
@ -293,7 +290,7 @@ module.exports = createReactClass({
if (!proceed) return;
this.setState({ updating: this.state.updating + 1 });
this.context.matrixClient.kick(
this.context.kick(
this.props.member.roomId, this.props.member.userId,
reason || undefined,
).then(function() {
@ -329,11 +326,11 @@ module.exports = createReactClass({
this.setState({ updating: this.state.updating + 1 });
let promise;
if (this.props.member.membership === 'ban') {
promise = this.context.matrixClient.unban(
promise = this.context.unban(
this.props.member.roomId, this.props.member.userId,
);
} else {
promise = this.context.matrixClient.ban(
promise = this.context.ban(
this.props.member.roomId, this.props.member.userId,
reason || undefined,
);
@ -360,7 +357,7 @@ module.exports = createReactClass({
onRedactAllMessages: async function() {
const {roomId, userId} = this.props.member;
const room = this.context.matrixClient.getRoom(roomId);
const room = this.context.getRoom(roomId);
if (!room) {
return;
}
@ -368,7 +365,7 @@ module.exports = createReactClass({
let eventsToRedact = [];
for (const timeline of timelineSet.getTimelines()) {
eventsToRedact = timeline.getEvents().reduce((events, event) => {
if (event.getSender() === userId && !event.isRedacted()) {
if (event.getSender() === userId && !event.isRedacted() && !event.isRedaction()) {
return events.concat(event);
} else {
return events;
@ -414,7 +411,7 @@ module.exports = createReactClass({
console.info(`Started redacting recent ${count} messages for ${user} in ${roomId}`);
await Promise.all(eventsToRedact.map(async event => {
try {
await this.context.matrixClient.redactEvent(roomId, event.getId());
await this.context.redactEvent(roomId, event.getId());
} catch (err) {
// log and swallow errors
console.error("Could not redact", event.getId());
@ -446,11 +443,11 @@ module.exports = createReactClass({
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const roomId = this.props.member.roomId;
const target = this.props.member.userId;
const room = this.context.matrixClient.getRoom(roomId);
const room = this.context.getRoom(roomId);
if (!room) return;
// if muting self, warn as it may be irreversible
if (target === this.context.matrixClient.getUserId()) {
if (target === this.context.getUserId()) {
try {
if (!(await this._warnSelfDemote())) return;
} catch (e) {
@ -478,7 +475,7 @@ module.exports = createReactClass({
if (!isNaN(level)) {
this.setState({ updating: this.state.updating + 1 });
this.context.matrixClient.setPowerLevel(roomId, target, level, powerLevelEvent).then(
this.context.setPowerLevel(roomId, target, level, powerLevelEvent).then(
function() {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
@ -500,13 +497,13 @@ module.exports = createReactClass({
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const roomId = this.props.member.roomId;
const target = this.props.member.userId;
const room = this.context.matrixClient.getRoom(roomId);
const room = this.context.getRoom(roomId);
if (!room) return;
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
if (!powerLevelEvent) return;
const me = room.getMember(this.context.matrixClient.credentials.userId);
const me = room.getMember(this.context.credentials.userId);
if (!me) return;
const defaultLevel = powerLevelEvent.getContent().users_default;
@ -515,7 +512,7 @@ module.exports = createReactClass({
// toggle the level
const newLevel = this.state.isTargetMod ? defaultLevel : modLevel;
this.setState({ updating: this.state.updating + 1 });
this.context.matrixClient.setPowerLevel(roomId, target, parseInt(newLevel), powerLevelEvent).then(
this.context.setPowerLevel(roomId, target, parseInt(newLevel), powerLevelEvent).then(
function() {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
@ -550,7 +547,7 @@ module.exports = createReactClass({
danger: true,
onFinished: (accepted) => {
if (!accepted) return;
this.context.matrixClient.deactivateSynapseUser(this.props.member.userId).catch(e => {
this.context.deactivateSynapseUser(this.props.member.userId).catch(e => {
console.error("Failed to deactivate user");
console.error(e);
@ -566,7 +563,7 @@ module.exports = createReactClass({
_applyPowerChange: function(roomId, target, powerLevel, powerLevelEvent) {
this.setState({ updating: this.state.updating + 1 });
this.context.matrixClient.setPowerLevel(roomId, target, parseInt(powerLevel), powerLevelEvent).then(
this.context.setPowerLevel(roomId, target, parseInt(powerLevel), powerLevelEvent).then(
function() {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
@ -587,7 +584,7 @@ module.exports = createReactClass({
onPowerChange: async function(powerLevel) {
const roomId = this.props.member.roomId;
const target = this.props.member.userId;
const room = this.context.matrixClient.getRoom(roomId);
const room = this.context.getRoom(roomId);
if (!room) return;
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
@ -598,7 +595,7 @@ module.exports = createReactClass({
return;
}
const myUserId = this.context.matrixClient.getUserId();
const myUserId = this.context.getUserId();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
@ -650,9 +647,9 @@ module.exports = createReactClass({
_calculateOpsPermissions: async function(member) {
let canDeactivate = false;
if (this.context.matrixClient) {
if (this.context) {
try {
canDeactivate = await this.context.matrixClient.isSynapseAdministrator();
canDeactivate = await this.context.isSynapseAdministrator();
} catch (e) {
console.error(e);
}
@ -665,13 +662,13 @@ module.exports = createReactClass({
},
muted: false,
};
const room = this.context.matrixClient.getRoom(member.roomId);
const room = this.context.getRoom(member.roomId);
if (!room) return defaultPerms;
const powerLevels = room.currentState.getStateEvents("m.room.power_levels", "");
if (!powerLevels) return defaultPerms;
const me = room.getMember(this.context.matrixClient.credentials.userId);
const me = room.getMember(this.context.credentials.userId);
if (!me) return defaultPerms;
const them = member;
@ -728,7 +725,7 @@ module.exports = createReactClass({
onCancel: function(e) {
dis.dispatch({
action: "view_user",
action: Action.ViewUser,
member: null,
});
},
@ -738,7 +735,7 @@ module.exports = createReactClass({
const avatarUrl = member.getMxcAvatarUrl();
if (!avatarUrl) return;
const httpUrl = this.context.matrixClient.mxcUrlToHttp(avatarUrl);
const httpUrl = this.context.mxcUrlToHttp(avatarUrl);
const ImageView = sdk.getComponent("elements.ImageView");
const params = {
src: httpUrl,
@ -767,9 +764,9 @@ module.exports = createReactClass({
// still loading
devComponents = <Spinner />;
} else if (devices === null) {
devComponents = _t("Unable to load device list");
devComponents = _t("Unable to load session list");
} else if (devices.length === 0) {
devComponents = _t("No devices with registered encryption keys");
devComponents = _t("No sessions with registered encryption keys");
} else {
devComponents = [];
for (let i = 0; i < devices.length; i++) {
@ -781,7 +778,7 @@ module.exports = createReactClass({
return (
<div>
<h3>{ _t("Devices") }</h3>
<h3>{ _t("Sessions") }</h3>
<div className="mx_MemberInfo_devices">
{ devComponents }
</div>
@ -797,7 +794,7 @@ module.exports = createReactClass({
},
_renderUserOptions: function() {
const cli = this.context.matrixClient;
const cli = this.context;
const member = this.props.member;
let ignoreButton = null;
@ -905,9 +902,9 @@ module.exports = createReactClass({
let synapseDeactivateButton;
let spinner;
if (this.props.member.userId !== this.context.matrixClient.credentials.userId) {
if (this.props.member.userId !== this.context.credentials.userId) {
// TODO: Immutable DMs replaces a lot of this
const dmRoomMap = new DMRoomMap(this.context.matrixClient);
const dmRoomMap = new DMRoomMap(this.context);
// dmRooms will not include dmRooms that we have been invited into but did not join.
// Because DMRoomMap runs off account_data[m.direct] which is only set on join of dm room.
// XXX: we potentially want DMs we have been invited to, to also show up here :L
@ -918,7 +915,7 @@ module.exports = createReactClass({
const tiles = [];
for (const roomId of dmRooms) {
const room = this.context.matrixClient.getRoom(roomId);
const room = this.context.getRoom(roomId);
if (room) {
const myMembership = room.getMyMembership();
// not a DM room if we have are not joined
@ -1064,12 +1061,12 @@ module.exports = createReactClass({
}
}
const room = this.context.matrixClient.getRoom(this.props.member.roomId);
const room = this.context.getRoom(this.props.member.roomId);
const powerLevelEvent = room ? room.currentState.getStateEvents("m.room.power_levels", "") : null;
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"];
const hsUrl = this.context.matrixClient.baseUrl;
const hsUrl = this.context.baseUrl;
let showPresence = true;
if (enablePresenceByHsUrl && enablePresenceByHsUrl[hsUrl] !== undefined) {
showPresence = enablePresenceByHsUrl[hsUrl];
@ -1108,16 +1105,17 @@ module.exports = createReactClass({
</div>
</div>;
const isEncrypted = this.context.matrixClient.isRoomEncrypted(this.props.member.roomId);
const isEncrypted = this.context.isRoomEncrypted(this.props.member.roomId);
if (this.state.e2eStatus && isEncrypted) {
e2eIconElement = (<E2EIcon status={this.state.e2eStatus} isUser={true} />);
}
}
const avatarUrl = this.props.member.getMxcAvatarUrl();
const {member} = this.props;
const avatarUrl = member.avatarUrl || (member.getMxcAvatarUrl && member.getMxcAvatarUrl());
let avatarElement;
if (avatarUrl) {
const httpUrl = this.context.matrixClient.mxcUrlToHttp(avatarUrl, 800, 800);
const httpUrl = this.context.mxcUrlToHttp(avatarUrl, 800, 800);
avatarElement = <div className="mx_MemberInfo_avatar">
<img src={httpUrl} />
</div>;

View file

@ -20,19 +20,23 @@ import React from 'react';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {isValid3pidInvite} from "../../../RoomInvite";
const MatrixClientPeg = require("../../../MatrixClientPeg");
const sdk = require('../../../index');
const rate_limited_func = require('../../../ratelimitedfunc');
const CallHandler = require("../../../CallHandler");
import rate_limited_func from "../../../ratelimitedfunc";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import CallHandler from "../../../CallHandler";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
const SHOW_MORE_INCREMENT = 100;
module.exports = createReactClass({
// Regex applied to filter our punctuation in member names before applying sort, to fuzzy it a little
// matches all ASCII punctuation: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
const SORT_REGEX = /[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/g;
export default createReactClass({
displayName: 'MemberList',
getInitialState: function() {
@ -45,7 +49,8 @@ module.exports = createReactClass({
}
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount: function() {
this._mounted = true;
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
@ -187,7 +192,7 @@ module.exports = createReactClass({
}
},
_updateList: new rate_limited_func(function() {
_updateList: rate_limited_func(function() {
this._updateListNow();
}, 500),
@ -336,10 +341,13 @@ module.exports = createReactClass({
}
// Fourth by name (alphabetical)
const nameA = memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name;
const nameB = memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name;
const nameA = (memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name).replace(SORT_REGEX, "");
const nameB = (memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name).replace(SORT_REGEX, "");
// console.log(`Comparing userA_name=${nameA} against userB_name=${nameB} - returning`);
return nameA.localeCompare(nameB);
return nameA.localeCompare(nameB, {
ignorePunctuation: true,
sensitivity: "base",
});
},
onSearchQueryChanged: function(searchQuery) {

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,16 +16,16 @@ limitations under the License.
*/
import SettingsStore from "../../../settings/SettingsStore";
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
const sdk = require('../../../index');
const dis = require('../../../dispatcher');
import * as sdk from "../../../index";
import dis from "../../../dispatcher/dispatcher";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import {Action} from "../../../dispatcher/actions";
module.exports = createReactClass({
export default createReactClass({
displayName: 'MemberTile',
propTypes: {
@ -41,29 +42,108 @@ module.exports = createReactClass({
getInitialState: function() {
return {
statusMessage: this.getStatusMessage(),
isRoomEncrypted: false,
e2eStatus: null,
};
},
componentDidMount() {
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return;
const cli = MatrixClientPeg.get();
if (SettingsStore.isFeatureEnabled("feature_custom_status")) {
const { user } = this.props.member;
if (user) {
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
}
const { user } = this.props.member;
if (!user) {
return;
if (SettingsStore.getValue("feature_cross_signing")) {
const { roomId } = this.props.member;
if (roomId) {
const isRoomEncrypted = cli.isRoomEncrypted(roomId);
this.setState({
isRoomEncrypted,
});
if (isRoomEncrypted) {
cli.on("userTrustStatusChanged", this.onUserTrustStatusChanged);
cli.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.updateE2EStatus();
} else {
// Listen for room to become encrypted
cli.on("RoomState.events", this.onRoomStateEvents);
}
}
}
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
},
componentWillUnmount() {
const cli = MatrixClientPeg.get();
const { user } = this.props.member;
if (!user) {
if (user) {
user.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
}
if (cli) {
cli.removeListener("RoomState.events", this.onRoomStateEvents);
cli.removeListener("userTrustStatusChanged", this.onUserTrustStatusChanged);
cli.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
}
},
onRoomStateEvents: function(ev) {
if (ev.getType() !== "m.room.encryption") return;
const { roomId } = this.props.member;
if (ev.getRoomId() !== roomId) return;
// The room is encrypted now.
const cli = MatrixClientPeg.get();
cli.removeListener("RoomState.events", this.onRoomStateEvents);
this.setState({
isRoomEncrypted: true,
});
this.updateE2EStatus();
},
onUserTrustStatusChanged: function(userId, trustStatus) {
if (userId !== this.props.member.userId) return;
this.updateE2EStatus();
},
onDeviceVerificationChanged: function(userId, deviceId, deviceInfo) {
if (userId !== this.props.member.userId) return;
this.updateE2EStatus();
},
updateE2EStatus: async function() {
const cli = MatrixClientPeg.get();
const { userId } = this.props.member;
const isMe = userId === cli.getUserId();
const userTrust = cli.checkUserTrust(userId);
if (!userTrust.isCrossSigningVerified()) {
this.setState({
e2eStatus: userTrust.wasCrossSigningVerified() ? "warning" : "normal",
});
return;
}
user.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
const devices = cli.getStoredDevicesForUser(userId);
const anyDeviceUnverified = devices.some(device => {
const { deviceId } = device;
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
const deviceTrust = cli.checkDeviceTrust(userId, deviceId);
return isMe ? !deviceTrust.isCrossSigningVerified() : !deviceTrust.isVerified();
});
this.setState({
e2eStatus: anyDeviceUnverified ? "warning" : "verified",
});
},
getStatusMessage() {
@ -95,12 +175,18 @@ module.exports = createReactClass({
) {
return true;
}
if (
nextState.isRoomEncrypted !== this.state.isRoomEncrypted ||
nextState.e2eStatus !== this.state.e2eStatus
) {
return true;
}
return false;
},
onClick: function(e) {
dis.dispatch({
action: 'view_user',
action: Action.ViewUser,
member: this.props.member,
});
},
@ -130,7 +216,7 @@ module.exports = createReactClass({
}
const av = (
<MemberAvatar member={member} width={36} height={36} />
<MemberAvatar member={member} width={36} height={36} aria-hidden="true" />
);
if (member.user) {
@ -154,14 +240,26 @@ module.exports = createReactClass({
const powerStatus = powerStatusMap.get(powerLevel);
let e2eStatus;
if (this.state.isRoomEncrypted) {
e2eStatus = this.state.e2eStatus;
}
return (
<EntityTile {...this.props} presenceState={presenceState}
<EntityTile
{...this.props}
presenceState={presenceState}
presenceLastActiveAgo={member.user ? member.user.lastActiveAgo : 0}
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
name={name} powerStatus={powerStatus} showPresence={this.props.showPresence}
avatarJsx={av}
title={this.getPowerLabel()}
name={name}
powerStatus={powerStatus}
showPresence={this.props.showPresence}
subtextLabel={statusMessage}
e2eStatus={e2eStatus}
onClick={this.onClick}
/>
);
},

View file

@ -18,13 +18,15 @@ import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import CallHandler from '../../../CallHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import RoomViewStore from '../../../stores/RoomViewStore';
import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
import ContentMessages from '../../../ContentMessages';
import E2EIcon from './E2EIcon';
import SettingsStore from "../../../settings/SettingsStore";
import {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
function ComposerAvatar(props) {
@ -133,14 +135,25 @@ class UploadButton extends React.Component {
roomId: PropTypes.string.isRequired,
}
constructor(props, context) {
super(props, context);
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);
}
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
}
onAction = payload => {
if (payload.action === "upload_file") {
this.onUploadClick();
}
};
onUploadClick(ev) {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
@ -153,7 +166,7 @@ class UploadButton extends React.Component {
if (ev.target.files.length === 0) return;
// take a copy so we can safely reset the value of the form control
// (Note it is a FileList: we can't use slice or sesnible iteration).
// (Note it is a FileList: we can't use slice or sensible iteration).
const tfiles = [];
for (let i = 0; i < ev.target.files.length; ++i) {
tfiles.push(ev.target.files[i]);
@ -191,10 +204,9 @@ class UploadButton extends React.Component {
}
export default class MessageComposer extends React.Component {
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this.onInputStateChanged = this.onInputStateChanged.bind(this);
this.onEvent = this.onEvent.bind(this);
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this);
@ -204,15 +216,11 @@ export default class MessageComposer extends React.Component {
isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(),
showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"),
};
}
componentDidMount() {
// N.B. using 'event' rather than 'RoomEvents' otherwise the crypto handler
// for 'event' fires *after* 'RoomEvent', and our room won't have yet been
// marked as encrypted.
// XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something.
MatrixClientPeg.get().on("event", this.onEvent);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._waitForOwnMember();
@ -236,7 +244,6 @@ export default class MessageComposer extends React.Component {
componentWillUnmount() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("event", this.onEvent);
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
}
if (this._roomStoreToken) {
@ -244,13 +251,6 @@ export default class MessageComposer extends React.Component {
}
}
onEvent(event) {
if (event.getType() !== 'm.room.encryption') return;
if (event.getRoomId() !== this.props.room.roomId) return;
// TODO: put (encryption state??) in state
this.forceUpdate();
}
_onRoomStateEvents(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId) return;
@ -308,18 +308,33 @@ export default class MessageComposer extends React.Component {
}
renderPlaceholderText() {
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
if (this.state.isQuoting) {
if (roomIsEncrypted) {
return _t('Send an encrypted reply…');
if (SettingsStore.getValue("feature_cross_signing")) {
if (this.state.isQuoting) {
if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
} else {
return _t('Send a reply…');
}
} else {
return _t('Send a reply (unencrypted)…');
if (this.props.e2eStatus) {
return _t('Send an encrypted message…');
} else {
return _t('Send a message…');
}
}
} else {
if (roomIsEncrypted) {
return _t('Send an encrypted message…');
if (this.state.isQuoting) {
if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
} else {
return _t('Send a reply (unencrypted)…');
}
} else {
return _t('Send a message (unencrypted)…');
if (this.props.e2eStatus) {
return _t('Send an encrypted message…');
} else {
return _t('Send a message (unencrypted)…');
}
}
}
}
@ -354,12 +369,22 @@ export default class MessageComposer extends React.Component {
room={this.props.room}
placeholder={this.renderPlaceholderText()}
permalinkCreator={this.props.permalinkCreator} />,
<EmojiButton key='stickerpicker_controls_button' addEmoji={this.addEmoji} />,
<EmojiButton key="stickerpicker_controls_button" addEmoji={this.addEmoji} />,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
callInProgress ? <HangupButton key="controls_hangup" roomId={this.props.room.roomId} /> : null,
callInProgress ? null : <CallButton key="controls_call" roomId={this.props.room.roomId} />,
callInProgress ? null : <VideoCallButton key="controls_videocall" roomId={this.props.room.roomId} />,
);
if (this.state.showCallButtons) {
if (callInProgress) {
controls.push(
<HangupButton key="controls_hangup" roomId={this.props.room.roomId} />,
);
} else {
controls.push(
<CallButton key="controls_call" roomId={this.props.room.roomId} />,
<VideoCallButton key="controls_videocall" roomId={this.props.room.roomId} />,
);
}
}
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
@ -372,7 +397,7 @@ export default class MessageComposer extends React.Component {
</a>
) : '';
controls.push(<div className="mx_MessageComposer_replaced_wrapper">
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")} />
<span className="mx_MessageComposer_roomReplaced_header">
@ -390,7 +415,7 @@ export default class MessageComposer extends React.Component {
}
return (
<div className="mx_MessageComposer">
<div className="mx_MessageComposer mx_GroupLayout">
<div className="mx_MessageComposer_wrapper">
<div className="mx_MessageComposer_row">
{ controls }

View file

@ -17,14 +17,15 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import * as sdk from '../../../index';
import classNames from 'classnames';
import AccessibleButton from "../elements/AccessibleButton";
export default class MessageComposerFormatBar extends React.PureComponent {
static propTypes = {
onAction: PropTypes.func.isRequired,
shortcuts: PropTypes.object.isRequired,
}
};
constructor(props) {
super(props);
@ -64,7 +65,7 @@ class FormatButton extends React.PureComponent {
icon: PropTypes.string.isRequired,
shortcut: PropTypes.string,
visible: PropTypes.bool,
}
};
render() {
const InteractiveTooltip = sdk.getComponent('elements.InteractiveTooltip');
@ -82,11 +83,12 @@ class FormatButton extends React.PureComponent {
return (
<InteractiveTooltip content={tooltipContent} forceHidden={!this.props.visible}>
<span aria-label={this.props.label}
role="button"
onClick={this.props.onClick}
className={className}>
</span>
<AccessibleButton
as="span"
role="button"
onClick={this.props.onClick}
aria-label={this.props.label}
className={className} />
</InteractiveTooltip>
);
}

File diff suppressed because it is too large Load diff

View file

@ -17,15 +17,15 @@ limitations under the License.
import React from "react";
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import MatrixClientPeg from "../../../MatrixClientPeg";
import dis from "../../../dispatcher";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import AccessibleButton from "../elements/AccessibleButton";
import MessageEvent from "../messages/MessageEvent";
import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from '../../../languageHandler';
import {formatFullDate} from '../../../DateUtils';
module.exports = createReactClass({
export default createReactClass({
displayName: 'PinnedEventTile',
propTypes: {
mxRoom: PropTypes.object.isRequired,

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 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.
@ -17,13 +18,13 @@ limitations under the License.
import React from "react";
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import MatrixClientPeg from "../../../MatrixClientPeg";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import AccessibleButton from "../elements/AccessibleButton";
import PinnedEventTile from "./PinnedEventTile";
import { _t } from '../../../languageHandler';
import PinningUtils from "../../../utils/PinningUtils";
module.exports = createReactClass({
export default createReactClass({
displayName: 'PinnedEventsPanel',
propTypes: {
// The Room from the js-sdk we're going to show pinned events for

View file

@ -21,7 +21,7 @@ import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
module.exports = createReactClass({
export default createReactClass({
displayName: 'PresenceLabel',
propTypes: {

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 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.
@ -14,18 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
const sdk = require('../../../index');
const Velociraptor = require('../../../Velociraptor');
require('../../../VelocityBounce');
import '../../../VelocityBounce';
import { _t } from '../../../languageHandler';
import {formatDate} from '../../../DateUtils';
import Velociraptor from "../../../Velociraptor";
import * as sdk from "../../../index";
import {toRem} from "../../../utils/units";
let bounce = false;
try {
@ -35,7 +33,7 @@ try {
} catch (e) {
}
module.exports = createReactClass({
export default createReactClass({
displayName: 'ReadReceiptMarker',
propTypes: {
@ -90,6 +88,11 @@ module.exports = createReactClass({
};
},
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._avatar = createRef();
},
componentWillUnmount: function() {
// before we remove the rr, store its location in the map, so that if
// it reappears, it can be animated from the right place.
@ -105,7 +108,7 @@ module.exports = createReactClass({
return;
}
const avatarNode = ReactDOM.findDOMNode(this);
const avatarNode = this._avatar.current;
rrInfo.top = avatarNode.offsetTop;
rrInfo.left = avatarNode.offsetLeft;
rrInfo.parent = avatarNode.offsetParent;
@ -125,7 +128,7 @@ module.exports = createReactClass({
oldTop = oldInfo.top + oldInfo.parent.getBoundingClientRect().top;
}
const newElement = ReactDOM.findDOMNode(this);
const newElement = this._avatar.current;
let startTopOffset;
if (!newElement.offsetParent) {
// this seems to happen sometimes for reasons I don't understand
@ -146,7 +149,7 @@ module.exports = createReactClass({
// start at the old height and in the old h pos
startStyles.push({ top: startTopOffset+"px",
left: oldInfo.left+"px" });
left: toRem(oldInfo.left) });
const reorderTransitionOpts = {
duration: 100,
@ -175,11 +178,11 @@ module.exports = createReactClass({
render: function() {
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
if (this.state.suppressDisplay) {
return <div />;
return <div ref={this._avatar} />;
}
const style = {
left: this.props.leftOffset+'px',
left: toRem(this.props.leftOffset),
top: '0px',
visibility: this.props.hidden ? 'hidden' : 'visible',
};
@ -215,6 +218,7 @@ module.exports = createReactClass({
style={style}
title={title}
onClick={this.props.onClick}
inputRef={this._avatar}
/>
</Velociraptor>
);

View file

@ -15,8 +15,8 @@ limitations under the License.
*/
import React from 'react';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
@ -35,8 +35,8 @@ export default class ReplyPreview extends React.Component {
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
};
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this.unmounted = false;
this.state = {

View file

@ -15,13 +15,13 @@ limitations under the License.
*/
import React, {createRef} from "react";
import dis from "../../../dispatcher";
import MatrixClientPeg from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
import RoomAvatar from '../avatars/RoomAvatar';
import classNames from 'classnames';
import sdk from "../../../index";
import * as sdk from "../../../index";
import Analytics from "../../../Analytics";
import * as RoomNotifs from '../../../RoomNotifs';
import * as FormattingUtils from "../../../utils/FormattingUtils";
@ -49,7 +49,8 @@ export default class RoomBreadcrumbs extends React.Component {
this._scroller = createRef();
}
componentWillMount() {
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
this._dispatcherRef = dis.register(this.onAction);
const storedRooms = SettingsStore.getValue("breadcrumb_rooms");
@ -362,17 +363,6 @@ export default class RoomBreadcrumbs extends React.Component {
badge = <div className={badgeClasses}>{r.formattedCount}</div>;
}
let dmIndicator;
if (this._isDmRoom(r.room)) {
dmIndicator = <img
src={require("../../../../res/img/icon_person.svg")}
className="mx_RoomBreadcrumbs_dmIndicator"
width="13"
height="15"
alt={_t("Direct Chat")}
/>;
}
return (
<AccessibleButton
className={classes}
@ -384,7 +374,6 @@ export default class RoomBreadcrumbs extends React.Component {
>
<RoomAvatar room={r.room} width={32} height={32} />
{badge}
{dmIndicator}
{tooltip}
</AccessibleButton>
);

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import sdk from '../../../index';
import dis from '../../../dispatcher';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import React from 'react';
import { _t } from '../../../languageHandler';
import PropTypes from 'prop-types';

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import sdk from '../../../index';
import * as sdk from '../../../index';
import React, {createRef} from 'react';
import { _t } from '../../../languageHandler';
import { linkifyElement } from '../../../HtmlUtils';
import { ContentRepo } from 'matrix-js-sdk';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
export function getDisplayAliasForRoom(room) {
return room.canonicalAlias || (room.aliases ? room.aliases[0] : "");
@ -54,6 +54,7 @@ export default createReactClass({
}
},
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._topic = createRef();
},
@ -101,7 +102,7 @@ export default createReactClass({
<td className="mx_RoomDirectory_roomAvatar">
<BaseAvatar width={24} height={24} resizeMethod='crop'
name={name} idName={name}
url={ContentRepo.getHttpUriForMxc(
url={getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
room.avatarUrl, 24, 24, "crop")} />
</td>

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
@ -17,7 +18,7 @@ limitations under the License.
import React from 'react';
import createReactClass from 'create-react-class';
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomDropTarget',
render: function() {

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
@ -18,9 +19,9 @@ import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import classNames from 'classnames';
import sdk from '../../../index';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import Modal from "../../../Modal";
import RateLimitedFunc from '../../../ratelimitedfunc';
@ -30,9 +31,11 @@ import ManageIntegsButton from '../elements/ManageIntegsButton';
import {CancelButton} from './SimpleRoomHeader';
import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
import DMRoomMap from '../../../utils/DMRoomMap';
import E2EIcon from './E2EIcon';
import InviteOnlyIcon from './InviteOnlyIcon';
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomHeader',
propTypes: {
@ -55,6 +58,7 @@ module.exports = createReactClass({
};
},
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._topic = createRef();
},
@ -159,6 +163,17 @@ module.exports = createReactClass({
<E2EIcon status={this.props.e2eStatus} /> :
undefined;
const dmUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
const joinRules = this.props.room && this.props.room.currentState.getStateEvents("m.room.join_rules", "");
const joinRule = joinRules && joinRules.getContent().join_rule;
let privateIcon;
// Don't show an invite-only icon for DMs. Users know they're invite-only.
if (!dmUserId && SettingsStore.getValue("feature_cross_signing")) {
if (joinRule == "invite") {
privateIcon = <InviteOnlyIcon />;
}
}
if (this.props.onCancelClick) {
cancelButton = <CancelButton onClick={this.props.onCancelClick} />;
}
@ -300,9 +315,9 @@ module.exports = createReactClass({
return (
<div className="mx_RoomHeader light-panel">
<div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
{ e2eIcon }
<div className="mx_RoomHeader_wrapper" aria-owns="mx_RightPanel">
<div className="mx_RoomHeader_avatar">{ roomAvatar }{ e2eIcon }</div>
{ privateIcon }
{ name }
{ topicElement }
{ cancelButton }

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 Vector Creations Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,29 +18,34 @@ limitations under the License.
import SettingsStore from "../../../settings/SettingsStore";
import Timer from "../../../utils/Timer";
import React from "react";
import ReactDOM from "react-dom";
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import * as utils from "matrix-js-sdk/src/utils";
import { _t } from '../../../languageHandler';
const MatrixClientPeg = require("../../../MatrixClientPeg");
const CallHandler = require('../../../CallHandler');
const dis = require("../../../dispatcher");
const sdk = require('../../../index');
const rate_limited_func = require('../../../ratelimitedfunc');
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import rate_limited_func from "../../../ratelimitedfunc";
import * as Rooms from '../../../Rooms';
import DMRoomMap from '../../../utils/DMRoomMap';
const Receipt = require('../../../utils/Receipt');
import TagOrderStore from '../../../stores/TagOrderStore';
import RoomListStore from '../../../stores/RoomListStore';
import CustomRoomTagStore from '../../../stores/CustomRoomTagStore';
import GroupStore from '../../../stores/GroupStore';
import RoomSubList from '../../structures/RoomSubList';
import ResizeHandle from '../elements/ResizeHandle';
import CallHandler from "../../../CallHandler";
import dis from "../../../dispatcher/dispatcher";
import * as sdk from "../../../index";
import * as Receipt from "../../../utils/Receipt";
import {Resizer} from '../../../resizer';
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy";
import {DefaultTagID} from "../../../stores/room-list/models";
import * as Unread from "../../../Unread";
import RoomViewStore from "../../../stores/RoomViewStore";
import {TAG_DM} from "../../../stores/RoomListStore";
const HIDE_CONFERENCE_CHANS = true;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
const HOVER_MOVE_TIMEOUT = 1000;
@ -49,7 +55,7 @@ function labelForTagName(tagName) {
return tagName;
}
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomList',
propTypes: {
@ -114,7 +120,8 @@ module.exports = createReactClass({
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Replace component with real class, put this in the constructor.
UNSAFE_componentWillMount: function() {
this.mounted = false;
const cli = MatrixClientPeg.get();
@ -156,7 +163,7 @@ module.exports = createReactClass({
this.updateVisibleRooms();
});
this._roomListStoreToken = RoomListStore.addListener(() => {
this._roomListStoreToken = RoomListStoreTempProxy.addListener(() => {
this._delayedRefreshRoomList();
});
@ -241,6 +248,55 @@ module.exports = createReactClass({
});
}
break;
case 'view_room_delta': {
const currentRoomId = RoomViewStore.getRoomId();
const {
"im.vector.fake.invite": inviteRooms,
"m.favourite": favouriteRooms,
[TAG_DM]: dmRooms,
"im.vector.fake.recent": recentRooms,
"m.lowpriority": lowPriorityRooms,
"im.vector.fake.archived": historicalRooms,
"m.server_notice": serverNoticeRooms,
...tags
} = this.state.lists;
const shownCustomTagRooms = Object.keys(tags).filter(tagName => {
return (!this.state.customTags || this.state.customTags[tagName]) &&
!tagName.match(STANDARD_TAGS_REGEX);
}).map(tagName => tags[tagName]);
// this order matches the one when generating the room sublists below.
let rooms = this._applySearchFilter([
...inviteRooms,
...favouriteRooms,
...dmRooms,
...recentRooms,
...[].concat.apply([], shownCustomTagRooms), // eslint-disable-line prefer-spread
...lowPriorityRooms,
...historicalRooms,
...serverNoticeRooms,
], this.props.searchFilter);
if (payload.unread) {
// filter to only notification rooms (and our current active room so we can index properly)
rooms = rooms.filter(room => {
return room.roomId === currentRoomId || Unread.doesRoomHaveUnreadMessages(room);
});
}
const currentIndex = rooms.findIndex(room => room.roomId === currentRoomId);
// use slice to account for looping around the start
const [room] = rooms.slice((currentIndex + payload.delta) % rooms.length);
if (room) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
});
}
break;
}
}
},
@ -384,7 +440,7 @@ module.exports = createReactClass({
this._delayedRefreshRoomList();
},
_delayedRefreshRoomList: new rate_limited_func(function() {
_delayedRefreshRoomList: rate_limited_func(function() {
this.refreshRoomList();
}, 500),
@ -467,7 +523,7 @@ module.exports = createReactClass({
},
getTagNameForRoomId: function(roomId) {
const lists = RoomListStore.getRoomLists();
const lists = RoomListStoreTempProxy.getRoomLists();
for (const tagName of Object.keys(lists)) {
for (const room of lists[tagName]) {
// Should be impossible, but guard anyways.
@ -487,7 +543,7 @@ module.exports = createReactClass({
},
getRoomLists: function() {
const lists = RoomListStore.getRoomLists();
const lists = RoomListStoreTempProxy.getRoomLists();
const filteredLists = {};
@ -589,10 +645,22 @@ module.exports = createReactClass({
_applySearchFilter: function(list, filter) {
if (filter === "") return list;
const lcFilter = filter.toLowerCase();
// apply toLowerCase before and after removeHiddenChars because different rules get applied
// e.g M -> M but m -> n, yet some unicode homoglyphs come out as uppercase, e.g 𝚮 -> H
const fuzzyFilter = utils.removeHiddenChars(lcFilter).toLowerCase();
// case insensitive if room name includes filter,
// or if starts with `#` and one of room's aliases starts with filter
return list.filter((room) => (room.name && room.name.toLowerCase().includes(lcFilter)) ||
(filter[0] === '#' && room.getAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))));
return list.filter((room) => {
if (filter[0] === "#") {
if (room.getCanonicalAlias() && room.getCanonicalAlias().toLowerCase().startsWith(lcFilter)) {
return true;
}
if (room.getAltAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))) {
return true;
}
}
return room.name && utils.removeHiddenChars(room.name.toLowerCase()).toLowerCase().includes(fuzzyFilter);
});
},
_handleCollapsedState: function(key, collapsed) {
@ -628,7 +696,6 @@ module.exports = createReactClass({
const defaultProps = {
collapsed: this.props.collapsed,
isFiltered: !!this.props.searchFilter,
incomingCall: this.state.incomingCall,
};
subListsProps.forEach((p) => {
@ -641,7 +708,7 @@ module.exports = createReactClass({
}));
return subListsProps.reduce((components, props, i) => {
props = Object.assign({}, defaultProps, props);
props = {...defaultProps, ...props};
const isLast = i === subListsProps.length - 1;
const len = props.list.length + (props.extraTiles ? props.extraTiles.length : 0);
const {key, label, onHeaderClick, ...otherProps} = props;
@ -652,12 +719,12 @@ module.exports = createReactClass({
onHeaderClick(collapsed);
}
};
let startAsHidden = props.startAsHidden || this.collapsedState[chosenKey];
const startAsHidden = props.startAsHidden || this.collapsedState[chosenKey];
this._layoutSections.push({
id: chosenKey,
count: len,
});
let subList = (<RoomSubList
const subList = (<RoomSubList
ref={this._subListRef.bind(this, chosenKey)}
startAsHidden={startAsHidden}
forceExpand={!!this.props.searchFilter}
@ -693,13 +760,11 @@ module.exports = createReactClass({
list: [],
extraTiles: this._makeGroupInviteTiles(this.props.searchFilter),
label: _t('Community Invites'),
order: "recent",
isInvite: true,
},
{
list: this.state.lists['im.vector.fake.invite'],
label: _t('Invites'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.invite'),
isInvite: true,
},
@ -707,24 +772,22 @@ module.exports = createReactClass({
list: this.state.lists['m.favourite'],
label: _t('Favourites'),
tagName: "m.favourite",
order: "manual",
incomingCall: incomingCallIfTaggedAs('m.favourite'),
},
{
list: this.state.lists['im.vector.fake.direct'],
label: _t('People'),
tagName: "im.vector.fake.direct",
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
list: this.state.lists[DefaultTagID.DM],
label: _t('Direct Messages'),
tagName: DefaultTagID.DM,
incomingCall: incomingCallIfTaggedAs(DefaultTagID.DM),
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
addRoomLabel: _t("Start chat"),
},
{
list: this.state.lists['im.vector.fake.recent'],
label: _t('Rooms'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
onAddRoom: () => {dis.dispatch({action: 'view_create_room'});},
addRoomLabel: _t("Create room"),
},
];
const tagSubLists = Object.keys(this.state.lists)
@ -737,7 +800,6 @@ module.exports = createReactClass({
key: tagName,
label: labelForTagName(tagName),
tagName: tagName,
order: "manual",
incomingCall: incomingCallIfTaggedAs(tagName),
};
});
@ -747,13 +809,11 @@ module.exports = createReactClass({
list: this.state.lists['m.lowpriority'],
label: _t('Low priority'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.lowpriority'),
},
{
list: this.state.lists['im.vector.fake.archived'],
label: _t('Historical'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.archived'),
startAsHidden: true,
showSpinner: this.state.isLoadingLeftRooms,
@ -763,26 +823,31 @@ module.exports = createReactClass({
list: this.state.lists['m.server_notice'],
label: _t('System Alerts'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.server_notice'),
},
]);
const subListComponents = this._mapSubListProps(subLists);
const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, ...props} = this.props; // eslint-disable-line
const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, onKeyDown, ...props} = this.props; // eslint-disable-line
return (
<div
{...props}
ref={this._collectResizeContainer}
className="mx_RoomList"
role="tree"
aria-label={_t("Rooms")}
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
>
{ subListComponents }
</div>
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({onKeyDownHandler}) => <div
{...props}
onKeyDown={onKeyDownHandler}
ref={this._collectResizeContainer}
className="mx_RoomList"
role="tree"
aria-label={_t("Rooms")}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex="-1"
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
>
{ subListComponents }
</div> }
</RovingTabIndexProvider>
);
},
});

View file

@ -0,0 +1,246 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 Vector Creations Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { _t, _td } from "../../../languageHandler";
import { Layout } from '../../../resizer/distributors/roomsublist2';
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore2";
import { ITagMap } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { Dispatcher } from "flux";
import dis from "../../../dispatcher/dispatcher";
import RoomSublist2 from "./RoomSublist2";
import { ActionPayload } from "../../../dispatcher/payloads";
/*******************************************************************
* CAUTION *
*******************************************************************
* This is a work in progress implementation and isn't complete or *
* even useful as a component. Please avoid using it until this *
* warning disappears. *
*******************************************************************/
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void;
resizeNotifier: ResizeNotifier;
collapsed: boolean;
searchFilter: string;
}
interface IState {
sublists: ITagMap;
}
const TAG_ORDER: TagID[] = [
// -- Community Invites Placeholder --
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Untagged,
// -- Custom Tags Placeholder --
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Archived,
];
const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite;
const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
const ALWAYS_VISIBLE_TAGS: TagID[] = [
DefaultTagID.DM,
DefaultTagID.Untagged,
];
interface ITagAesthetics {
sectionLabel: string;
addRoomLabel?: string;
onAddRoom?: (dispatcher: Dispatcher<ActionPayload>) => void;
isInvite: boolean;
defaultHidden: boolean;
}
const TAG_AESTHETICS: {
// @ts-ignore - TS wants this to be a string but we know better
[tagId: TagID]: ITagAesthetics;
} = {
[DefaultTagID.Invite]: {
sectionLabel: _td("Invites"),
isInvite: true,
defaultHidden: false,
},
[DefaultTagID.Favourite]: {
sectionLabel: _td("Favourites"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.DM]: {
sectionLabel: _td("Direct Messages"),
isInvite: false,
defaultHidden: false,
addRoomLabel: _td("Start chat"),
onAddRoom: (dispatcher: Dispatcher<ActionPayload>) => dispatcher.dispatch({action: 'view_create_chat'}),
},
[DefaultTagID.Untagged]: {
sectionLabel: _td("Rooms"),
isInvite: false,
defaultHidden: false,
addRoomLabel: _td("Create room"),
onAddRoom: (dispatcher: Dispatcher<ActionPayload>) => dispatcher.dispatch({action: 'view_create_room'}),
},
[DefaultTagID.LowPriority]: {
sectionLabel: _td("Low priority"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.ServerNotice]: {
sectionLabel: _td("System Alerts"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.Archived]: {
sectionLabel: _td("Historical"),
isInvite: false,
defaultHidden: true,
},
};
export default class RoomList2 extends React.Component<IProps, IState> {
private sublistRefs: { [tagId: string]: React.RefObject<RoomSublist2> } = {};
private sublistSizes: { [tagId: string]: number } = {};
private sublistCollapseStates: { [tagId: string]: boolean } = {};
private unfilteredLayout: Layout;
private filteredLayout: Layout;
constructor(props: IProps) {
super(props);
this.state = {sublists: {}};
this.loadSublistSizes();
this.prepareLayouts();
}
public componentDidMount(): void {
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store) => {
console.log("new lists", store.orderedLists);
this.setState({sublists: store.orderedLists});
});
}
private loadSublistSizes() {
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
if (sizesJson) this.sublistSizes = JSON.parse(sizesJson);
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
if (collapsedJson) this.sublistCollapseStates = JSON.parse(collapsedJson);
}
private saveSublistSizes() {
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.sublistSizes));
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.sublistCollapseStates));
}
private prepareLayouts() {
// TODO: Change layout engine for FTUE support
this.unfilteredLayout = new Layout((tagId: string, height: number) => {
const sublist = this.sublistRefs[tagId];
if (sublist) sublist.current.setHeight(height);
// TODO: Check overflow (see old impl)
// Don't store a height for collapsed sublists
if (!this.sublistCollapseStates[tagId]) {
this.sublistSizes[tagId] = height;
this.saveSublistSizes();
}
}, this.sublistSizes, this.sublistCollapseStates, {
allowWhitespace: false,
handleHeight: 1,
});
this.filteredLayout = new Layout((tagId: string, height: number) => {
const sublist = this.sublistRefs[tagId];
if (sublist) sublist.current.setHeight(height);
}, null, null, {
allowWhitespace: false,
handleHeight: 0,
});
}
private renderSublists(): React.ReactElement[] {
const components: React.ReactElement[] = [];
for (const orderedTagId of TAG_ORDER) {
if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) {
// Populate community invites if we have the chance
// TODO
}
if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
// Populate custom tags if needed
// TODO
}
const orderedRooms = this.state.sublists[orderedTagId] || [];
if (orderedRooms.length === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
continue; // skip tag - not needed
}
const aesthetics: ITagAesthetics = TAG_AESTHETICS[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
components.push(<RoomSublist2
key={`sublist-${orderedTagId}`}
forRooms={true}
rooms={orderedRooms}
startAsHidden={aesthetics.defaultHidden}
label={_t(aesthetics.sectionLabel)}
onAddRoom={onAddRoomFn}
addRoomLabel={aesthetics.addRoomLabel}
isInvite={aesthetics.isInvite}
/>);
}
return components;
}
public render() {
const sublists = this.renderSublists();
return (
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={this.props.onKeyDown}>
{({onKeyDownHandler}) => (
<div
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
onKeyDown={onKeyDownHandler}
className="mx_RoomList"
role="tree"
aria-label={_t("Rooms")}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>{sublists}</div>
)}
</RovingTabIndexProvider>
);
}
}

View file

@ -17,11 +17,11 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
const sdk = require('../../../index');
const MatrixClientPeg = require('../../../MatrixClientPeg');
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import { _t } from '../../../languageHandler';
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomNameEditor',
propTypes: {
@ -34,7 +34,8 @@ module.exports = createReactClass({
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount: function() {
const room = this.props.room;
const name = room.currentState.getStateEvents('m.room.name', '');
const myId = MatrixClientPeg.get().credentials.userId;

View file

@ -19,9 +19,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import IdentityAuthClient from '../../../IdentityAuthClient';
@ -43,12 +43,13 @@ const MessageCase = Object.freeze({
OtherError: "OtherError",
});
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomPreviewBar',
propTypes: {
onJoinClick: PropTypes.func,
onRejectClick: PropTypes.func,
onRejectAndIgnoreClick: PropTypes.func,
onForgetClick: PropTypes.func,
// if inviterName is specified, the preview bar will shown an invite to the room.
// You should also specify onRejectClick if specifiying inviterName
@ -96,7 +97,7 @@ module.exports = createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
this._checkInvitedEmail();
},
@ -265,9 +266,9 @@ module.exports = createReactClass({
params: {
email: this.props.invitedEmail,
signurl: this.props.signUrl,
room_name: this.props.oobData.room_name,
room_avatar_url: this.props.oobData.avatarUrl,
inviter_name: this.props.oobData.inviterName,
room_name: this.props.oobData ? this.props.oobData.room_name : null,
room_avatar_url: this.props.oobData ? this.props.oobData.avatarUrl : null,
inviter_name: this.props.oobData ? this.props.oobData.inviterName : null,
}
};
},
@ -282,6 +283,7 @@ module.exports = createReactClass({
render: function() {
const Spinner = sdk.getComponent('elements.Spinner');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let showSpinner = false;
let darkStyle = false;
@ -292,6 +294,7 @@ module.exports = createReactClass({
let secondaryActionHandler;
let secondaryActionLabel;
let footer;
const extraComponents = [];
const messageCase = this._getMessageCase();
switch (messageCase) {
@ -469,6 +472,14 @@ module.exports = createReactClass({
primaryActionHandler = this.props.onJoinClick;
secondaryActionLabel = _t("Reject");
secondaryActionHandler = this.props.onRejectClick;
if (this.props.onRejectAndIgnoreClick) {
extraComponents.push(
<AccessibleButton kind="secondary" onClick={this.props.onRejectAndIgnoreClick} key="ignore">
{ _t("Reject & Ignore user") }
</AccessibleButton>,
);
}
break;
}
case MessageCase.ViewingRoom: {
@ -498,15 +509,13 @@ module.exports = createReactClass({
"<issueLink>submit a bug report</issueLink>.",
{ errcode: this.props.error.errcode },
{ issueLink: label => <a href="https://github.com/vector-im/riot-web/issues/new/choose"
target="_blank" rel="noopener">{ label }</a> },
target="_blank" rel="noreferrer noopener">{ label }</a> },
),
];
break;
}
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let subTitleElements;
if (subTitle) {
if (!Array.isArray(subTitle)) {
@ -554,6 +563,7 @@ module.exports = createReactClass({
</div>
<div className="mx_RoomPreviewBar_actions">
{ secondaryButton }
{ extraComponents }
{ primaryButton }
</div>
<div className="mx_RoomPreviewBar_footer">

View file

@ -1,5 +1,6 @@
/*
Copyright 2018, 2019 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,10 +17,10 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import sdk from "../../../index";
import * as sdk from "../../../index";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import MatrixClientPeg from "../../../MatrixClientPeg";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
export default class RoomRecoveryReminder extends React.PureComponent {
@ -43,7 +44,7 @@ export default class RoomRecoveryReminder extends React.PureComponent {
};
}
componentWillMount() {
componentDidMount() {
this._loadBackupStatus();
}
@ -60,7 +61,6 @@ export default class RoomRecoveryReminder extends React.PureComponent {
loading: false,
error: e,
});
return;
}
}
@ -70,10 +70,14 @@ export default class RoomRecoveryReminder extends React.PureComponent {
// verified, so restore the backup which will give us the keys from it and
// allow us to trust it (ie. upload keys to it)
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {});
Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog, null, null,
/* priority = */ false, /* static = */ true,
);
} else {
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"),
null, null, /* priority = */ false, /* static = */ true,
);
}
}
@ -119,7 +123,7 @@ export default class RoomRecoveryReminder extends React.PureComponent {
let setupCaption;
if (this.state.backupInfo) {
setupCaption = _t("Connect this device to Key Backup");
setupCaption = _t("Connect this session to Key Backup");
} else {
setupCaption = _t("Start using Key Backup");
}
@ -146,18 +150,18 @@ export default class RoomRecoveryReminder extends React.PureComponent {
)}</p>
</div>
<div className="mx_RoomRecoveryReminder_buttons">
<AccessibleButton className="mx_RoomRecoveryReminder_button"
<AccessibleButton kind="primary"
onClick={this.onSetupClick}>
{setupCaption}
</AccessibleButton>
<p><AccessibleButton className="mx_RoomRecoveryReminder_secondary mx_linkButton"
<AccessibleButton className="mx_RoomRecoveryReminder_secondary mx_linkButton"
onClick={this.onOnNotNowClick}>
{ _t("Not now") }
</AccessibleButton></p>
<p><AccessibleButton className="mx_RoomRecoveryReminder_secondary mx_linkButton"
</AccessibleButton>
<AccessibleButton className="mx_RoomRecoveryReminder_secondary mx_linkButton"
onClick={this.onDontAskAgainClick}>
{ _t("Don't ask me again") }
</AccessibleButton></p>
</AccessibleButton>
</div>
</div>
);

View file

@ -0,0 +1,226 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 Vector Creations Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from 'classnames';
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
import * as RoomNotifs from '../../../RoomNotifs';
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../../views/elements/AccessibleButton";
import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton";
import * as FormattingUtils from '../../../utils/FormattingUtils';
import RoomTile2 from "./RoomTile2";
/*******************************************************************
* CAUTION *
*******************************************************************
* This is a work in progress implementation and isn't complete or *
* even useful as a component. Please avoid using it until this *
* warning disappears. *
*******************************************************************/
interface IProps {
forRooms: boolean;
rooms?: Room[];
startAsHidden: boolean;
label: string;
onAddRoom?: () => void;
addRoomLabel: string;
isInvite: boolean;
// TODO: Collapsed state
// TODO: Height
// TODO: Group invites
// TODO: Calls
// TODO: forceExpand?
// TODO: Header clicking
// TODO: Spinner support for historical
}
interface IState {
}
export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef();
public setHeight(size: number) {
// TODO: Do a thing (maybe - height changes are different in FTUE)
}
private hasTiles(): boolean {
return this.numTiles > 0;
}
private get numTiles(): number {
// TODO: Account for group invites
return (this.props.rooms || []).length;
}
private onAddRoom = (e) => {
e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom();
};
private renderTiles(): React.ReactElement[] {
const tiles: React.ReactElement[] = [];
if (this.props.rooms) {
for (const room of this.props.rooms) {
tiles.push(<RoomTile2 room={room} key={`room-${room.roomId}`}/>);
}
}
return tiles;
}
private renderHeader(): React.ReactElement {
const notifications = !this.props.isInvite
? RoomNotifs.aggregateNotificationCount(this.props.rooms)
: {count: 0, highlight: true};
const notifCount = notifications.count;
const notifHighlight = notifications.highlight;
// TODO: Title on collapsed
// TODO: Incoming call box
let chevron = null;
if (this.hasTiles()) {
const chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': false, // isCollapsed
'mx_RoomSubList_chevronDown': true, // !isCollapsed
});
chevron = (<div className={chevronClasses}/>);
}
return (
<RovingTabIndexWrapper inputRef={this.headerButton}>
{({onFocus, isActive, ref}) => {
// TODO: Use onFocus
const tabIndex = isActive ? 0 : -1;
// TODO: Collapsed state
let badge;
if (true) { // !isCollapsed
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': notifHighlight,
});
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
if (notifCount > 0) {
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
aria-label={_t("Jump to first unread room.")}
>
<div>
{FormattingUtils.formatCount(notifCount)}
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.hasTiles()) {
// Render the `!` badge for invites
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
aria-label={_t("Jump to first invite.")}
>
<div>
{FormattingUtils.formatCount(this.numTiles)}
</div>
</AccessibleButton>
);
}
}
let addRoomButton = null;
if (!!this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
// TODO: a11y (see old component)
return (
<div className={"mx_RoomSubList_labelContainer"}>
<AccessibleButton
inputRef={ref}
tabIndex={tabIndex}
className={"mx_RoomSubList_label"}
role="treeitem"
aria-level="1"
>
{chevron}
<span>{this.props.label}</span>
</AccessibleButton>
{badge}
{addRoomButton}
</div>
);
}}
</RovingTabIndexWrapper>
);
}
public render(): React.ReactElement {
// TODO: Proper rendering
// TODO: Error boundary
const tiles = this.renderTiles();
const classes = classNames({
// TODO: Proper collapse support
'mx_RoomSubList': true,
'mx_RoomSubList_hidden': false, // len && isCollapsed
'mx_RoomSubList_nonEmpty': this.hasTiles(), // len && !isCollapsed
});
let content = null;
if (tiles.length > 0) {
// TODO: Lazy list rendering
// TODO: Whatever scrolling magic needs to happen here
content = (
<IndicatorScrollbar className='mx_RoomSubList_scroll'>
{tiles}
</IndicatorScrollbar>
)
}
// TODO: onKeyDown support
return (
<div
className={classes}
role="group"
aria-label={this.props.label}
>
{this.renderHeader()}
{content}
</div>
);
}
}

View file

@ -17,14 +17,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import classNames from 'classnames';
import dis from '../../../dispatcher';
import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import DMRoomMap from '../../../utils/DMRoomMap';
import sdk from '../../../index';
import * as sdk from '../../../index';
import {ContextMenu, ContextMenuButton, toRightOf} from '../../structures/ContextMenu';
import * as RoomNotifs from '../../../RoomNotifs';
import * as FormattingUtils from '../../../utils/FormattingUtils';
@ -32,8 +32,14 @@ import ActiveRoomObserver from '../../../ActiveRoomObserver';
import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
import {_t} from "../../../languageHandler";
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
import E2EIcon from './E2EIcon';
import InviteOnlyIcon from './InviteOnlyIcon';
// eslint-disable-next-line camelcase
import rate_limited_func from '../../../ratelimitedfunc';
import { shieldStatusForRoom } from '../../../utils/ShieldUtils';
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomTile',
propTypes: {
@ -56,7 +62,11 @@ module.exports = createReactClass({
},
getInitialState: function() {
const joinRules = this.props.room.currentState.getStateEvents("m.room.join_rules", "");
const joinRule = joinRules && joinRules.getContent().join_rule;
return ({
joinRule,
hover: false,
badgeHover: false,
contextMenuPosition: null, // DOM bounding box, null if non-shown
@ -65,14 +75,10 @@ module.exports = createReactClass({
notificationCount: this.props.room.getUnreadNotificationCount(),
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
statusMessage: this._getStatusMessage(),
e2eStatus: null,
});
},
_isDirectMessageRoom: function(roomId) {
const dmRooms = DMRoomMap.shared().getUserIdForRoomId(roomId);
return Boolean(dmRooms);
},
_shouldShowStatusMessage() {
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return false;
@ -102,6 +108,63 @@ module.exports = createReactClass({
return statusUser._unstable_statusMessage;
},
onRoomStateMember: function(ev, state, member) {
// we only care about leaving users
// because trust state will change if someone joins a megolm session anyway
if (member.membership !== "leave") {
return;
}
// ignore members in other rooms
if (member.roomId !== this.props.room.roomId) {
return;
}
this._updateE2eStatus();
},
onUserVerificationChanged: function(userId, _trustStatus) {
if (!this.props.room.getMember(userId)) {
// Not in this room
return;
}
this._updateE2eStatus();
},
onCrossSigningKeysChanged: function() {
this._updateE2eStatus();
},
onRoomTimeline: function(ev, room) {
if (!room) return;
if (room.roomId != this.props.room.roomId) return;
if (ev.getType() !== "m.room.encryption") return;
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
this.onFindingRoomToBeEncrypted();
},
onFindingRoomToBeEncrypted: function() {
const cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember);
cli.on("userTrustStatusChanged", this.onUserVerificationChanged);
cli.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this._updateE2eStatus();
},
_updateE2eStatus: async function() {
const cli = MatrixClientPeg.get();
if (!cli.isRoomEncrypted(this.props.room.roomId)) {
return;
}
if (!SettingsStore.getValue("feature_cross_signing")) {
return;
}
/* At this point, the user has encryption on and cross-signing on */
this.setState({
e2eStatus: await shieldStatusForRoom(cli, this.props.room),
});
},
onRoomName: function(room) {
if (room !== this.props.room) return;
this.setState({
@ -109,6 +172,12 @@ module.exports = createReactClass({
});
},
onJoinRule: function(ev) {
if (ev.getType() !== "m.room.join_rules") return;
if (ev.getRoomId() !== this.props.room.roomId) return;
this.setState({ joinRule: ev.getContent().join_rule });
},
onAccountData: function(accountDataEvent) {
if (accountDataEvent.getType() === 'm.push_rules') {
this.setState({
@ -135,19 +204,49 @@ module.exports = createReactClass({
case 'feature_custom_status_changed':
this.forceUpdate();
break;
case 'view_room':
// when the room is selected make sure its tile is visible, for breadcrumbs/keyboard shortcut access
if (payload.room_id === this.props.room.roomId && payload.show_room_tile) {
this._scrollIntoView();
}
break;
}
},
_scrollIntoView: function() {
if (!this._roomTile.current) return;
this._roomTile.current.scrollIntoView({
block: "nearest",
behavior: "auto",
});
},
_onActiveRoomChange: function() {
this.setState({
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
});
},
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._roomTile = createRef();
},
componentDidMount: function() {
/* We bind here rather than in the definition because otherwise we wind up with the
method only being callable once every 500ms across all instances, which would be wrong */
this._updateE2eStatus = rate_limited_func(this._updateE2eStatus, 500);
const cli = MatrixClientPeg.get();
cli.on("accountData", this.onAccountData);
cli.on("Room.name", this.onRoomName);
cli.on("RoomState.events", this.onJoinRule);
if (cli.isRoomEncrypted(this.props.room.roomId)) {
this.onFindingRoomToBeEncrypted();
} else {
cli.on("Room.timeline", this.onRoomTimeline);
}
ActiveRoomObserver.addListener(this.props.room.roomId, this._onActiveRoomChange);
this.dispatcherRef = dis.register(this.onAction);
@ -157,6 +256,11 @@ module.exports = createReactClass({
statusUser.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
}
// when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
if (this.state.selected) {
this._scrollIntoView();
}
},
componentWillUnmount: function() {
@ -164,6 +268,11 @@ module.exports = createReactClass({
if (cli) {
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
cli.removeListener("RoomState.events", this.onJoinRule);
cli.removeListener("RoomState.members", this.onRoomStateMember);
cli.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
cli.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
cli.removeListener("Room.timeline", this.onRoomTimeline);
}
ActiveRoomObserver.removeListener(this.props.room.roomId, this._onActiveRoomChange);
dis.unregister(this.dispatcherRef);
@ -179,7 +288,8 @@ module.exports = createReactClass({
}
},
componentWillReceiveProps: function(props) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(props) {
// XXX: This could be a lot better - this makes the assumption that
// the notification count may have changed when the properties of
// the room tile change.
@ -297,6 +407,8 @@ module.exports = createReactClass({
const isMenuDisplayed = Boolean(this.state.contextMenuPosition);
const dmUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
const classes = classNames({
'mx_RoomTile': true,
'mx_RoomTile_selected': this.state.selected,
@ -320,10 +432,9 @@ module.exports = createReactClass({
});
let name = this.state.roomName;
if (name == undefined || name == null) name = '';
if (typeof name !== 'string') name = '';
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
let badge;
if (badges) {
const limitedCount = FormattingUtils.formatCount(notificationCount);
@ -342,7 +453,8 @@ module.exports = createReactClass({
});
subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null;
label = <div title={name} className={nameClasses} dir="auto">{ name }</div>;
// XXX: this is a workaround for Firefox giving this div a tabstop :( [tabIndex]
label = <div title={name} className={nameClasses} tabIndex={-1} dir="auto">{ name }</div>;
} else if (this.state.hover) {
const Tooltip = sdk.getComponent("elements.Tooltip");
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />;
@ -371,15 +483,12 @@ module.exports = createReactClass({
let ariaLabel = name;
let dmIndicator;
if (this._isDirectMessageRoom(this.props.room.roomId)) {
dmIndicator = <img
src={require("../../../../res/img/icon_person.svg")}
className="mx_RoomTile_dm"
width="11"
height="13"
alt="dm"
/>;
let dmOnline;
const { room } = this.props;
const member = room.getMember(dmUserId);
if (member && member.membership === "join" && room.getJoinedMemberCount() === 2) {
const UserOnlineDot = sdk.getComponent('rooms.UserOnlineDot');
dmOnline = <UserOnlineDot userId={dmUserId} />;
}
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
@ -405,35 +514,55 @@ module.exports = createReactClass({
);
}
let privateIcon = null;
if (SettingsStore.getValue("feature_cross_signing")) {
if (this.state.joinRule == "invite" && !dmUserId) {
privateIcon = <InviteOnlyIcon collapsedPanel={this.props.collapsed} />;
}
}
let e2eIcon = null;
if (this.state.e2eStatus) {
e2eIcon = <E2EIcon status={this.state.e2eStatus} className="mx_RoomTile_e2eIcon" />;
}
return <React.Fragment>
<AccessibleButton
tabIndex="0"
className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
aria-label={ariaLabel}
aria-selected={this.state.selected}
role="treeitem"
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24} />
{ dmIndicator }
</div>
</div>
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
{ label }
{ subtextLabel }
</div>
{ contextMenuButton }
{ badge }
</div>
{ /* { incomingCallBox } */ }
{ tooltip }
</AccessibleButton>
<RovingTabIndexWrapper inputRef={this._roomTile}>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
aria-label={ariaLabel}
aria-selected={this.state.selected}
role="treeitem"
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24} />
{ e2eIcon }
</div>
</div>
{ privateIcon }
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
{ label }
{ subtextLabel }
</div>
{ dmOnline }
{ contextMenuButton }
{ badge }
</div>
{ /* { incomingCallBox } */ }
{ tooltip }
</AccessibleButton>
}
</RovingTabIndexWrapper>
{ contextMenu }
</React.Fragment>;

View file

@ -0,0 +1,277 @@
/*
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.
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, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomAvatar from "../../views/avatars/RoomAvatar";
import Tooltip from "../../views/elements/Tooltip";
import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard";
import * as RoomNotifs from '../../../RoomNotifs';
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
import * as Unread from '../../../Unread';
import * as FormattingUtils from "../../../utils/FormattingUtils";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
/*******************************************************************
* CAUTION *
*******************************************************************
* This is a work in progress implementation and isn't complete or *
* even useful as a component. Please avoid using it until this *
* warning disappears. *
*******************************************************************/
enum NotificationColor {
// Inverted (None -> Red) because we do integer comparisons on this
None, // nothing special
Bold, // no badge, show as unread
Grey, // unread notified messages
Red, // unread pings
}
interface IProps {
room: Room;
// TODO: Allow falsifying counts (for invites and stuff)
// TODO: Transparency? Was this ever used?
// TODO: Incoming call boxes?
}
interface INotificationState {
symbol: string;
color: NotificationColor;
}
interface IState {
hover: boolean;
notificationState: INotificationState;
}
export default class RoomTile2 extends React.Component<IProps, IState> {
private roomTile = createRef();
// TODO: Custom status
// TODO: Lock icon
// TODO: Presence indicator
// TODO: e2e shields
// TODO: Handle changes to room aesthetics (name, join rules, etc)
// TODO: scrollIntoView?
// TODO: hover, badge, etc
// TODO: isSelected for hover effects
// TODO: Context menu
// TODO: a11y
constructor(props: IProps) {
super(props);
this.state = {
hover: false,
notificationState: this.getNotificationState(),
};
this.props.room.on("Room.receipt", this.handleRoomEventUpdate);
this.props.room.on("Room.timeline", this.handleRoomEventUpdate);
this.props.room.on("Room.redaction", this.handleRoomEventUpdate);
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
}
public componentWillUnmount() {
if (this.props.room) {
this.props.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
this.props.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.props.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
}
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
}
}
// XXX: This is a bit of an awful-looking hack. We should probably be using state for
// this, but instead we're kinda forced to either duplicate the code or thread a variable
// through the code paths. This feels like the least evil option.
private get roomIsInvite(): boolean {
return getEffectiveMembership(this.props.room.getMyMembership()) === EffectiveMembership.Invite;
}
private handleRoomEventUpdate = (event: MatrixEvent) => {
const roomId = event.getRoomId();
// Sanity check: should never happen
if (roomId !== this.props.room.roomId) return;
this.updateNotificationState();
};
private updateNotificationState() {
this.setState({notificationState: this.getNotificationState()});
}
private getNotificationState(): INotificationState {
const state: INotificationState = {
color: NotificationColor.None,
symbol: null,
};
if (this.roomIsInvite) {
state.color = NotificationColor.Red;
state.symbol = "!";
} else {
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'highlight');
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'total');
// For a 'true count' we pick the grey notifications first because they include the
// red notifications. If we don't have a grey count for some reason we use the red
// count. If that count is broken for some reason, assume zero. This avoids us showing
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
// Note: we only set the symbol if we have an actual count. We don't want to show
// zero on badges.
if (redNotifs > 0) {
state.color = NotificationColor.Red;
state.symbol = FormattingUtils.formatCount(trueCount);
} else if (greyNotifs > 0) {
state.color = NotificationColor.Grey;
state.symbol = FormattingUtils.formatCount(trueCount);
} else {
// We don't have any notified messages, but we might have unread messages. Let's
// find out.
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.props.room);
if (hasUnread) {
state.color = NotificationColor.Bold;
// no symbol for this state
}
}
}
return state;
}
private onTileMouseEnter = () => {
this.setState({hover: true});
};
private onTileMouseLeave = () => {
this.setState({hover: false});
};
private onTileClick = (ev: React.KeyboardEvent) => {
dis.dispatch({
action: 'view_room',
// TODO: Support show_room_tile in new room list
show_room_tile: true, // make sure the room is visible in the list
room_id: this.props.room.roomId,
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
});
};
public render(): React.ReactElement {
// TODO: Collapsed state
// TODO: Invites
// TODO: a11y proper
// TODO: Render more than bare minimum
const hasBadge = this.state.notificationState.color > NotificationColor.Bold;
const isUnread = this.state.notificationState.color > NotificationColor.None;
const classes = classNames({
'mx_RoomTile': true,
// 'mx_RoomTile_selected': this.state.selected,
'mx_RoomTile_unread': isUnread,
'mx_RoomTile_unreadNotify': this.state.notificationState.color >= NotificationColor.Grey,
'mx_RoomTile_highlight': this.state.notificationState.color >= NotificationColor.Red,
'mx_RoomTile_invited': this.roomIsInvite,
// 'mx_RoomTile_menuDisplayed': isMenuDisplayed,
'mx_RoomTile_noBadges': !hasBadge,
// 'mx_RoomTile_transparent': this.props.transparent,
// 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
});
const avatarClasses = classNames({
'mx_RoomTile_avatar': true,
});
let badge;
if (hasBadge) {
const badgeClasses = classNames({
'mx_RoomTile_badge': true,
'mx_RoomTile_badgeButton': false, // this.state.badgeHover || isMenuDisplayed
});
badge = <div className={badgeClasses}>{this.state.notificationState.symbol}</div>;
}
// TODO: the original RoomTile uses state for the room name. Do we need to?
let name = this.props.room.name;
if (typeof name !== 'string') name = '';
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
const nameClasses = classNames({
'mx_RoomTile_name': true,
'mx_RoomTile_invite': this.roomIsInvite,
'mx_RoomTile_badgeShown': hasBadge,
});
// TODO: Support collapsed state properly
let tooltip = null;
if (false) { // isCollapsed
if (this.state.hover) {
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} />
}
}
return (
<React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTile}>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes}
onMouseEnter={this.onTileMouseEnter}
onMouseLeave={this.onTileMouseLeave}
onClick={this.onTileClick}
role="treeitem"
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24}/>
</div>
</div>
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
</div>
</div>
{badge}
</div>
{tooltip}
</AccessibleButton>
}
</RovingTabIndexWrapper>
</React.Fragment>
);
}
}

View file

@ -17,10 +17,10 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import * as sdk from '../../../index';
import { _t } from "../../../languageHandler";
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomTopicEditor',
propTypes: {
@ -33,7 +33,7 @@ module.exports = createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
const room = this.props.room;
const topic = room.currentState.getStateEvents('m.room.topic', '');
this.setState({

View file

@ -17,13 +17,13 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from "../../../MatrixClientPeg";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomUpgradeWarningBar',
propTypes: {
@ -31,7 +31,7 @@ module.exports = createReactClass({
recommendation: PropTypes.object.isRequired,
},
componentWillMount: function() {
componentDidMount: function() {
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});

View file

@ -16,12 +16,12 @@ limitations under the License.
import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
const classNames = require('classnames');
const AccessibleButton = require('../../../components/views/elements/AccessibleButton');
import AccessibleButton from "../elements/AccessibleButton";
import classNames from "classnames";
import { _t } from '../../../languageHandler';
import {Key} from "../../../Keyboard";
module.exports = createReactClass({
export default createReactClass({
displayName: 'SearchBar',
getInitialState: function() {
@ -30,6 +30,7 @@ module.exports = createReactClass({
});
},
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._search_term = createRef();
},

View file

@ -1,5 +1,6 @@
/*
Copyright 2015 OpenMarket Ltd
Copyright 2019 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.
@ -17,9 +18,10 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import * as sdk from '../../../index';
import {haveTileForEvent} from "./EventTile";
module.exports = createReactClass({
export default createReactClass({
displayName: 'SearchResult',
propTypes: {
@ -53,7 +55,7 @@ module.exports = createReactClass({
if (!contextual) {
highlights = this.props.searchHighlights;
}
if (EventTile.haveTileForEvent(ev)) {
if (haveTileForEvent(ev)) {
ret.push(<EventTile key={eventId+"+"+j} mxEvent={ev} contextual={contextual} highlights={highlights}
permalinkCreator={this.props.permalinkCreator}
highlightLink={this.props.resultLink}

View file

@ -1,185 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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 PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import sdk from "../../../index";
import { _t } from '../../../languageHandler';
// A list capable of displaying entities which conform to the SearchableEntity
// interface which is an object containing getJsx(): Jsx and matches(query: string): boolean
const SearchableEntityList = createReactClass({
displayName: 'SearchableEntityList',
propTypes: {
emptyQueryShowsAll: PropTypes.bool,
showInputBox: PropTypes.bool,
onQueryChanged: PropTypes.func, // fn(inputText)
onSubmit: PropTypes.func, // fn(inputText)
entities: PropTypes.array,
truncateAt: PropTypes.number,
},
getDefaultProps: function() {
return {
showInputBox: true,
entities: [],
emptyQueryShowsAll: false,
onSubmit: function() {},
onQueryChanged: function(input) {},
};
},
getInitialState: function() {
return {
query: "",
focused: false,
truncateAt: this.props.truncateAt,
results: this.getSearchResults("", this.props.entities),
};
},
componentWillReceiveProps: function(newProps) {
// recalculate the search results in case we got new entities
this.setState({
results: this.getSearchResults(this.state.query, newProps.entities),
});
},
componentWillUnmount: function() {
// pretend the query box was blanked out else filters could still be
// applied to other components which rely on onQueryChanged.
this.props.onQueryChanged("");
},
/**
* Public-facing method to set the input query text to the given input.
* @param {string} input
*/
setQuery: function(input) {
this.setState({
query: input,
results: this.getSearchResults(input, this.props.entities),
});
},
onQueryChanged: function(ev) {
const q = ev.target.value;
this.setState({
query: q,
// reset truncation if they back out the entire text
truncateAt: (q.length === 0 ? this.props.truncateAt : this.state.truncateAt),
results: this.getSearchResults(q, this.props.entities),
}, () => {
// invoke the callback AFTER we've flushed the new state. We need to
// do this because onQueryChanged can result in new props being passed
// to this component, which will then try to recalculate the search
// list. If we do this without flushing, we'll recalc with the last
// search term and not the current one!
this.props.onQueryChanged(q);
});
},
onQuerySubmit: function(ev) {
ev.preventDefault();
this.props.onSubmit(this.state.query);
},
getSearchResults: function(query, entities) {
if (!query || query.length === 0) {
return this.props.emptyQueryShowsAll ? entities : [];
}
return entities.filter(function(e) {
return e.matches(query);
});
},
_showAll: function() {
this.setState({
truncateAt: -1,
});
},
_createOverflowEntity: function(overflowCount, totalCount) {
const EntityTile = sdk.getComponent("rooms.EntityTile");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={this._showAll} />
);
},
render: function() {
let inputBox;
if (this.props.showInputBox) {
inputBox = (
<form onSubmit={this.onQuerySubmit} autoComplete="off">
<input className="mx_SearchableEntityList_query" id="mx_SearchableEntityList_query" type="text"
onChange={this.onQueryChanged} value={this.state.query}
onFocus= {() => { this.setState({ focused: true }); }}
onBlur= {() => { this.setState({ focused: false }); }}
placeholder={_t("Search")} />
</form>
);
}
let list;
if (this.state.results.length > 1 || this.state.focused) {
if (this.props.truncateAt) { // caller wants list truncated
const TruncatedList = sdk.getComponent("elements.TruncatedList");
list = (
<TruncatedList className="mx_SearchableEntityList_list"
truncateAt={this.state.truncateAt} // use state truncation as it may be expanded
createOverflowElement={this._createOverflowEntity}>
{ this.state.results.map((entity) => {
return entity.getJsx();
}) }
</TruncatedList>
);
} else {
list = (
<div className="mx_SearchableEntityList_list">
{ this.state.results.map((entity) => {
return entity.getJsx();
}) }
</div>
);
}
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
list = (
<GeminiScrollbarWrapper autoshow={true}
className="mx_SearchableEntityList_listWrapper">
{ list }
</GeminiScrollbarWrapper>
);
}
return (
<div className={"mx_SearchableEntityList " + (list ? "mx_SearchableEntityList_expanded" : "")}>
{ inputBox }
{ list }
{ list ? <div className="mx_SearchableEntityList_hrWrapper"><hr /></div> : '' }
</div>
);
},
});
module.exports = SearchableEntityList;

View file

@ -16,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import EditorModel from '../../../editor/model';
import {
htmlSerializeIfNeeded,
@ -24,9 +24,10 @@ import {
containsEmote,
stripEmoteCommand,
unescapeMessage,
startsWith,
stripPrefix,
} from '../../../editor/serialize';
import {CommandPartCreator} from '../../../editor/parts';
import {MatrixClient} from 'matrix-js-sdk';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyPreview from "./ReplyPreview";
import RoomViewStore from '../../../stores/RoomViewStore';
@ -34,12 +35,15 @@ import ReplyThread from "../elements/ReplyThread";
import {parseEvent} from '../../../editor/deserialize';
import {findEditableEvent} from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager";
import {processCommandInput} from '../../../SlashCommands';
import sdk from '../../../index';
import {getCommand} from '../../../SlashCommands';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import {_t, _td} from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages';
import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RateLimitedFunc from '../../../ratelimitedfunc';
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -56,11 +60,15 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
}
}
function createMessageContent(model, permalinkCreator) {
// exported for tests
export function createMessageContent(model, permalinkCreator) {
const isEmote = containsEmote(model);
if (isEmote) {
model = stripEmoteCommand(model);
}
if (startsWith(model, "//")) {
model = stripPrefix(model, "/");
}
model = unescapeMessage(model);
const repliedToEvent = RoomViewStore.getQuotingEvent();
@ -89,15 +97,19 @@ export default class SendMessageComposer extends React.Component {
permalinkCreator: PropTypes.object.isRequired,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
};
static contextType = MatrixClientContext;
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this.model = null;
this._editorRef = null;
this.currentlyComposedEditorState = null;
const cli = MatrixClientPeg.get();
if (cli.isCryptoEnabled() && cli.isRoomEncrypted(this.props.room.roomId)) {
this._prepareToEncrypt = new RateLimitedFunc(() => {
cli.prepareToEncrypt(this.props.room);
}, 60000);
}
}
_setEditorRef = ref => {
@ -117,14 +129,23 @@ export default class SendMessageComposer extends React.Component {
this.onVerticalArrow(event, true);
} else if (event.key === Key.ARROW_DOWN) {
this.onVerticalArrow(event, false);
} else if (this._prepareToEncrypt) {
this._prepareToEncrypt();
} else if (event.key === Key.ESCAPE) {
dis.dispatch({
action: 'reply_to_event',
event: null,
});
}
}
};
onVerticalArrow(e, up) {
if (e.ctrlKey || e.shiftKey || e.metaKey) return;
// arrows from an initial-caret composer navigates recent messages to edit
// ctrl-alt-arrows navigate send history
if (e.shiftKey || e.metaKey) return;
const shouldSelectHistory = e.altKey;
const shouldEditLastMessage = !e.altKey && up && !RoomViewStore.getQuotingEvent();
const shouldSelectHistory = e.altKey && e.ctrlKey;
const shouldEditLastMessage = !e.altKey && !e.ctrlKey && up && !RoomViewStore.getQuotingEvent();
if (shouldSelectHistory) {
// Try select composer history
@ -177,20 +198,21 @@ export default class SendMessageComposer extends React.Component {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command") {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
// be extra resilient when somehow the AutocompleteWrapperModel or
// CommandPartCreator fails to insert a command part, so we don't send
// a command as a message
if (firstPart.text.startsWith("/") && (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
return true;
}
}
return false;
}
async _runSlashCommand() {
_getSlashCommand() {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
@ -198,54 +220,90 @@ export default class SendMessageComposer extends React.Component {
}
return text + part.text;
}, "");
const cmd = processCommandInput(this.props.room.roomId, commandText);
return [getCommand(this.props.room.roomId, commandText), commandText];
}
if (cmd) {
let error = cmd.error;
if (cmd.promise) {
try {
await cmd.promise;
} catch (err) {
error = err;
}
async _runSlashCommand(fn) {
const cmd = fn();
let error = cmd.error;
if (cmd.promise) {
try {
await cmd.promise;
} catch (err) {
error = err;
}
if (error) {
console.error("Command failure: %s", error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// assume the error is a server error when the command is async
const isServerError = !!cmd.promise;
const title = isServerError ? _td("Server error") : _td("Command error");
}
if (error) {
console.error("Command failure: %s", error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// assume the error is a server error when the command is async
const isServerError = !!cmd.promise;
const title = isServerError ? _td("Server error") : _td("Command error");
let errText;
if (typeof error === 'string') {
errText = error;
} else if (error.message) {
errText = error.message;
} else {
errText = _t("Server unavailable, overloaded, or something else went wrong.");
}
Modal.createTrackedDialog(title, '', ErrorDialog, {
title: _t(title),
description: errText,
});
let errText;
if (typeof error === 'string') {
errText = error;
} else if (error.message) {
errText = error.message;
} else {
console.log("Command success.");
errText = _t("Server unavailable, overloaded, or something else went wrong.");
}
Modal.createTrackedDialog(title, '', ErrorDialog, {
title: _t(title),
description: errText,
});
} else {
console.log("Command success.");
}
}
_sendMessage() {
async _sendMessage() {
if (this.model.isEmpty) {
return;
}
let shouldSend = true;
if (!containsEmote(this.model) && this._isSlashCommand()) {
this._runSlashCommand();
} else {
const [cmd, commandText] = this._getSlashCommand();
if (cmd) {
shouldSend = false;
this._runSlashCommand(cmd);
} else {
// ask the user if their unknown command should be sent as a message
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
title: _t("Unknown Command"),
description: <div>
<p>
{ _t("Unrecognised command: %(commandText)s", {commandText}) }
</p>
<p>
{ _t("You can use <code>/help</code> to list available commands. " +
"Did you mean to send this as a message?", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
<p>
{ _t("Hint: Begin your message with <code>//</code> to start it with a slash.", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
</div>,
button: _t('Send as message'),
});
const [sendAnyway] = await finished;
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
}
}
if (shouldSend) {
const isReply = !!RoomViewStore.getQuotingEvent();
const {roomId} = this.props.room;
const content = createMessageContent(this.model, this.props.permalinkCreator);
this.context.matrixClient.sendMessage(roomId, content);
this.context.sendMessage(roomId, content);
if (isReply) {
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
@ -254,7 +312,9 @@ export default class SendMessageComposer extends React.Component {
event: null,
});
}
dis.dispatch({action: "message_sent"});
}
this.sendHistoryManager.save(this.model);
// clear composer
this.model.reset([]);
@ -272,8 +332,9 @@ export default class SendMessageComposer extends React.Component {
this._editorRef.getEditableRootNode().removeEventListener("paste", this._onPaste, true);
}
componentWillMount() {
const partCreator = new CommandPartCreator(this.props.room, this.context.matrixClient);
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
const partCreator = new CommandPartCreator(this.props.room, this.context);
const parts = this._restoreStoredEditorState(partCreator) || [];
this.model = new EditorModel(parts, partCreator);
this.dispatcherRef = dis.register(this.onAction);
@ -331,7 +392,8 @@ export default class SendMessageComposer extends React.Component {
member.rawDisplayName : userId;
const caret = this._editorRef.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
const insertIndex = position.index + 1;
// 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);
model.transform(() => {
const addedLen = model.insert(parts, position);
@ -375,7 +437,7 @@ export default class SendMessageComposer extends React.Component {
// from Finder) but more images copied from a different website
// / word processor etc.
ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(clipboardData.files), this.props.room.roomId, this.context.matrixClient,
Array.from(clipboardData.files), this.props.room.roomId, this.context,
);
}
}

View file

@ -18,7 +18,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
// cancel button which is shared between room header and simple room header

View file

@ -1,485 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
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, {createRef} from 'react';
import PropTypes from 'prop-types';
import { _t, _td } from '../../../languageHandler';
import CallHandler from '../../../CallHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
import ContentMessages from '../../../ContentMessages';
import E2EIcon from './E2EIcon';
const formatButtonList = [
_td("bold"),
_td("italic"),
_td("deleted"),
_td("underlined"),
_td("inline-code"),
_td("block-quote"),
_td("bulleted-list"),
_td("numbered-list"),
];
function ComposerAvatar(props) {
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,
}
function CallButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const onVoiceCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
type: "voice",
room_id: props.roomId,
});
};
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
onClick={onVoiceCallClick}
title={_t('Voice call')}
/>
}
CallButton.propTypes = {
roomId: PropTypes.string.isRequired
}
function VideoCallButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const onCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video",
room_id: props.roomId,
});
};
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_videocall"
onClick={onCallClick}
title={_t('Video call')}
/>;
}
VideoCallButton.propTypes = {
roomId: PropTypes.string.isRequired,
};
function HangupButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const onHangupClick = () => {
const call = CallHandler.getCallForRoom(props.roomId);
if (!call) {
return;
}
dis.dispatch({
action: 'hangup',
// hangup the call for this room, which may not be the room in props
// (e.g. conferences which will hangup the 1:1 room instead)
room_id: call.roomId,
});
};
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
onClick={onHangupClick}
title={_t('Hangup')}
/>;
}
HangupButton.propTypes = {
roomId: PropTypes.string.isRequired,
}
function FormattingButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <AccessibleButton
element="img"
className="mx_MessageComposer_formatting"
alt={_t("Show Text Formatting Toolbar")}
title={_t("Show Text Formatting Toolbar")}
src={require("../../../../res/img/button-text-formatting.svg")}
style={{visibility: props.showFormatting ? 'hidden' : 'visible'}}
onClick={props.onClickHandler}
/>;
}
FormattingButton.propTypes = {
showFormatting: PropTypes.bool.isRequired,
onClickHandler: PropTypes.func.isRequired,
}
class UploadButton extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
}
constructor(props, context) {
super(props, context);
this.onUploadClick = this.onUploadClick.bind(this);
this.onUploadFileInputChange = this.onUploadFileInputChange.bind(this);
this._uploadInput = createRef();
}
onUploadClick(ev) {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
this._uploadInput.current.click();
}
onUploadFileInputChange(ev) {
if (ev.target.files.length === 0) return;
// take a copy so we can safely reset the value of the form control
// (Note it is a FileList: we can't use slice or sesnible iteration).
const tfiles = [];
for (let i = 0; i < ev.target.files.length; ++i) {
tfiles.push(ev.target.files[i]);
}
ContentMessages.sharedInstance().sendContentListToRoom(
tfiles, this.props.roomId, MatrixClientPeg.get(),
);
// This is the onChange handler for a file form control, but we're
// not keeping any state, so reset the value of the form control
// to empty.
// NB. we need to set 'value': the 'files' property is immutable.
ev.target.value = '';
}
render() {
const uploadInputStyle = {display: 'none'};
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_upload"
onClick={this.onUploadClick}
title={_t('Upload file')}
>
<input ref={this._uploadInput} type="file"
style={uploadInputStyle}
multiple
onChange={this.onUploadFileInputChange}
/>
</AccessibleButton>
);
}
}
export default class SlateMessageComposer extends React.Component {
constructor(props, context) {
super(props, context);
this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this);
this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this);
this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this);
this.onInputStateChanged = this.onInputStateChanged.bind(this);
this.onEvent = this.onEvent.bind(this);
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this);
this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
this.renderFormatBar = this.renderFormatBar.bind(this);
this.state = {
inputState: {
marks: [],
blockType: null,
isRichTextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'),
},
showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'),
isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(),
};
}
componentDidMount() {
// N.B. using 'event' rather than 'RoomEvents' otherwise the crypto handler
// for 'event' fires *after* 'RoomEvent', and our room won't have yet been
// marked as encrypted.
// XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something.
MatrixClientPeg.get().on("event", this.onEvent);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._waitForOwnMember();
}
_waitForOwnMember() {
// if we have the member already, do that
const me = this.props.room.getMember(MatrixClientPeg.get().getUserId());
if (me) {
this.setState({me});
return;
}
// Otherwise, wait for member loading to finish and then update the member for the avatar.
// The members should already be loading, and loadMembersIfNeeded
// will return the promise for the existing operation
this.props.room.loadMembersIfNeeded().then(() => {
const me = this.props.room.getMember(MatrixClientPeg.get().getUserId());
this.setState({me});
});
}
componentWillUnmount() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("event", this.onEvent);
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
}
if (this._roomStoreToken) {
this._roomStoreToken.remove();
}
}
onEvent(event) {
if (event.getType() !== 'm.room.encryption') return;
if (event.getRoomId() !== this.props.room.roomId) return;
this.forceUpdate();
}
_onRoomStateEvents(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId) return;
if (ev.getType() === 'm.room.tombstone') {
this.setState({tombstone: this._getRoomTombstone()});
}
if (ev.getType() === 'm.room.power_levels') {
this.setState({canSendMessages: this.props.room.maySendMessage()});
}
}
_getRoomTombstone() {
return this.props.room.currentState.getStateEvents('m.room.tombstone', '');
}
_onRoomViewStoreUpdate() {
const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
if (this.state.isQuoting === isQuoting) return;
this.setState({ isQuoting });
}
onInputStateChanged(inputState) {
// Merge the new input state with old to support partial updates
inputState = Object.assign({}, this.state.inputState, inputState);
this.setState({inputState});
}
_onAutocompleteConfirm(range, completion) {
if (this.messageComposerInput) {
this.messageComposerInput.setDisplayedCompletion(range, completion);
}
}
onFormatButtonClicked(name, event) {
event.preventDefault();
this.messageComposerInput.onFormatButtonClicked(name, event);
}
onToggleFormattingClicked() {
SettingsStore.setValue("MessageComposer.showFormatting", null, SettingLevel.DEVICE, !this.state.showFormatting);
this.setState({showFormatting: !this.state.showFormatting});
}
onToggleMarkdownClicked(e) {
e.preventDefault(); // don't steal focus from the editor!
this.messageComposerInput.enableRichtext(!this.state.inputState.isRichTextEnabled);
}
_onTombstoneClick(ev) {
ev.preventDefault();
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
const replacementRoom = MatrixClientPeg.get().getRoom(replacementRoomId);
let createEventId = null;
if (replacementRoom) {
const createEvent = replacementRoom.currentState.getStateEvents('m.room.create', '');
if (createEvent && createEvent.getId()) createEventId = createEvent.getId();
}
const viaServers = [this.state.tombstone.getSender().split(':').splice(1).join(':')];
dis.dispatch({
action: 'view_room',
highlighted: true,
event_id: createEventId,
room_id: replacementRoomId,
auto_join: true,
// Try to join via the server that sent the event. This converts @something:example.org
// into a server domain by splitting on colons and ignoring the first entry ("@something").
via_servers: viaServers,
opts: {
// These are passed down to the js-sdk's /join call
viaServers: viaServers,
},
});
}
renderPlaceholderText() {
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
if (this.state.isQuoting) {
if (roomIsEncrypted) {
return _t('Send an encrypted reply…');
} else {
return _t('Send a reply (unencrypted)…');
}
} else {
if (roomIsEncrypted) {
return _t('Send an encrypted message…');
} else {
return _t('Send a message (unencrypted)…');
}
}
}
renderFormatBar() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const {marks, blockType} = this.state.inputState;
const formatButtons = formatButtonList.map((name) => {
// special-case to match the md serializer and the special-case in MessageComposerInput.js
const markName = name === 'inline-code' ? 'code' : name;
const active = marks.some(mark => mark.type === markName) || blockType === name;
const suffix = active ? '-on' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
const className = 'mx_MessageComposer_format_button mx_filterFlipColor';
return (
<img className={className}
title={_t(name)}
onMouseDown={onFormatButtonClicked}
key={name}
src={require(`../../../../res/img/button-text-${name}${suffix}.svg`)}
height="17"
/>
);
})
return (
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar">
{ formatButtons }
<div style={{ flex: 1 }}></div>
<AccessibleButton
className="mx_MessageComposer_formatbar_markdown mx_MessageComposer_markdownDisabled"
onClick={this.onToggleMarkdownClicked}
title={_t("Markdown is disabled")}
/>
<AccessibleButton element="img" title={_t("Hide Text Formatting Toolbar")}
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src={require("../../../../res/img/icon-text-cancel.svg")}
/>
</div>
</div>
);
}
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.props.e2eStatus ? <E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> : null,
];
if (!this.state.tombstone && this.state.canSendMessages) {
// This also currently includes the call buttons. Really we should
// check separately for whether we can call, but this is slightly
// complex because of conference calls.
const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
const showFormattingButton = this.state.inputState.isRichTextEnabled;
const callInProgress = this.props.callState && this.props.callState !== 'ended';
controls.push(
<MessageComposerInput
ref={(c) => this.messageComposerInput = c}
key="controls_input"
room={this.props.room}
placeholder={this.renderPlaceholderText()}
onInputStateChanged={this.onInputStateChanged}
permalinkCreator={this.props.permalinkCreator} />,
showFormattingButton ? <FormattingButton key="controls_formatting"
showFormatting={this.state.showFormatting} onClickHandler={this.onToggleFormattingClicked} /> : null,
<Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
callInProgress ? <HangupButton key="controls_hangup" roomId={this.props.room.roomId} /> : null,
callInProgress ? null : <CallButton key="controls_call" roomId={this.props.room.roomId} />,
callInProgress ? null : <VideoCallButton key="controls_videocall" roomId={this.props.room.roomId} />,
);
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
const continuesLink = replacementRoomId ? (
<a href={makeRoomPermalink(replacementRoomId)}
className="mx_MessageComposer_roomReplaced_link"
onClick={this._onTombstoneClick}
>
{_t("The conversation continues here.")}
</a>
) : '';
controls.push(<div className="mx_MessageComposer_replaced_wrapper">
<div className="mx_MessageComposer_replaced_valign">
<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 />
{ continuesLink }
</div>
</div>);
} else {
controls.push(
<div key="controls_error" className="mx_MessageComposer_noperm_error">
{ _t('You do not have permission to post to this room') }
</div>,
);
}
const showFormatBar = this.state.showFormatting && this.state.inputState.isRichTextEnabled;
return (
<div className="mx_MessageComposer">
<div className="mx_MessageComposer_wrapper">
<div className="mx_MessageComposer_row">
{ controls }
</div>
</div>
{ showFormatBar ? this.renderFormatBar() : null }
</div>
);
}
}
SlateMessageComposer.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

@ -16,9 +16,9 @@ limitations under the License.
import React from 'react';
import {_t, _td} from '../../../languageHandler';
import AppTile from '../elements/AppTile';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
import WidgetUtils from '../../../utils/WidgetUtils';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
@ -26,8 +26,7 @@ import PersistedElement from "../elements/PersistedElement";
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import {ContextMenu} from "../../structures/ContextMenu";
const widgetType = 'm.stickerpicker';
import {WidgetType} from "../../../widgets/WidgetType";
// This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
// We sit in a context menu, so this should be given to the context menu.
@ -85,11 +84,11 @@ export default class Stickerpicker extends React.Component {
async _removeStickerpickerWidgets() {
const scalarClient = await this._acquireScalarClient();
console.warn('Removing Stickerpicker widgets');
console.log('Removing Stickerpicker widgets');
if (this.state.widgetId) {
if (scalarClient) {
scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => {
console.warn('Assets disabled');
scalarClient.disableWidgetAssets(WidgetType.STICKERPICKER, this.state.widgetId).then(() => {
console.log('Assets disabled');
}).catch((err) => {
console.error('Failed to disable assets');
});
@ -241,6 +240,15 @@ export default class Stickerpicker extends React.Component {
// Set default name
stickerpickerWidget.content.name = stickerpickerWidget.name || _t("Stickerpack");
// FIXME: could this use the same code as other apps?
const stickerApp = {
id: stickerpickerWidget.id,
url: stickerpickerWidget.content.url,
name: stickerpickerWidget.content.name,
type: stickerpickerWidget.content.type,
data: stickerpickerWidget.content.data,
};
stickersContent = (
<div className='mx_Stickers_content_container'>
<div
@ -254,11 +262,8 @@ export default class Stickerpicker extends React.Component {
>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} style={{zIndex: STICKERPICKER_Z_INDEX}}>
<AppTile
id={stickerpickerWidget.id}
url={stickerpickerWidget.content.url}
name={stickerpickerWidget.content.name}
app={stickerApp}
room={this.props.room}
type={stickerpickerWidget.content.type}
fullWidth={true}
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
@ -316,8 +321,8 @@ export default class Stickerpicker extends React.Component {
// Offset the chevron location, which is relative to the left of the context menu
// (10 = offset when context menu would not be displayed off viewport)
// (8 = value required in practice (possibly 10 - 2 where the 2 = context menu borders)
const stickerPickerChevronOffset = Math.max(10, 8 + window.pageXOffset + buttonRect.left - x);
// (2 = context menu borders)
const stickerPickerChevronOffset = Math.max(10, 2 + window.pageXOffset + buttonRect.left - x);
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
@ -359,13 +364,13 @@ export default class Stickerpicker extends React.Component {
if (SettingsStore.isFeatureEnabled("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(
this.props.room,
`type_${widgetType}`,
`type_${WidgetType.STICKERPICKER.preferred}`,
this.state.widgetId,
);
} else {
IntegrationManagers.sharedInstance().getPrimaryManager().open(
this.props.room,
`type_${widgetType}`,
`type_${WidgetType.STICKERPICKER.preferred}`,
this.state.widgetId,
);
}

View file

@ -16,11 +16,11 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from "../../../MatrixClientPeg";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {MatrixEvent} from "matrix-js-sdk";
import {_t} from "../../../languageHandler";
import dis from "../../../dispatcher";
import sdk from "../../../index";
import dis from "../../../dispatcher/dispatcher";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import {isValid3pidInvite} from "../../../RoomInvite";
@ -51,7 +51,7 @@ export default class ThirdPartyMemberInfo extends React.Component {
};
}
componentWillMount(): void {
componentDidMount(): void {
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
}

View file

@ -22,11 +22,12 @@ import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
module.exports = createReactClass({
export default createReactClass({
displayName: 'TopUnreadMessagesBar',
propTypes: {
onScrollUpClick: PropTypes.func,
onCloseClick: PropTypes.func,
},
render: function() {
@ -36,6 +37,10 @@ module.exports = createReactClass({
title={_t('Jump to first unread message.')}
onClick={this.props.onScrollUpClick}>
</AccessibleButton>
<AccessibleButton className="mx_TopUnreadMessagesBar_markAsRead"
title={_t('Mark all as read')}
onClick={this.props.onCloseClick}>
</AccessibleButton>
</div>
);
},

View file

@ -0,0 +1,48 @@
/*
Copyright 2019 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, {useContext, useEffect, useMemo, useState, useCallback} from "react";
import PropTypes from "prop-types";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
const UserOnlineDot = ({userId}) => {
const cli = useContext(MatrixClientContext);
const user = useMemo(() => cli.getUser(userId), [cli, userId]);
const [isOnline, setIsOnline] = useState(false);
// Recheck if the user or client changes
useEffect(() => {
setIsOnline(user && (user.currentlyActive || user.presence === "online"));
}, [cli, user]);
// Recheck also if we receive a User.currentlyActive event
const currentlyActiveHandler = useCallback((ev) => {
const content = ev.getContent();
setIsOnline(content.currently_active || content.presence === "online");
}, []);
useEventEmitter(user, "User.currentlyActive", currentlyActiveHandler);
useEventEmitter(user, "User.presence", currentlyActiveHandler);
return isOnline ? <span className="mx_UserOnlineDot" /> : null;
};
UserOnlineDot.propTypes = {
userId: PropTypes.string.isRequired,
};
export default UserOnlineDot;

View file

@ -1,53 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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 PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
const Avatar = require("../../../Avatar");
const sdk = require('../../../index');
module.exports = createReactClass({
displayName: 'UserTile',
propTypes: {
user: PropTypes.any.isRequired, // User
},
render: function() {
const EntityTile = sdk.getComponent("rooms.EntityTile");
const user = this.props.user;
const name = user.displayName || user.userId;
let active = -1;
// FIXME: make presence data update whenever User.presence changes...
active = user.lastActiveAgo ?
(Date.now() - (user.lastPresenceTs - user.lastActiveAgo)) : -1;
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const avatarJsx = (
<BaseAvatar width={36} height={36} name={name} idName={user.userId}
url={Avatar.avatarUrlForUser(user, 36, 36, "crop")} />
);
return (
<EntityTile {...this.props} presenceState={user.presence} presenceActiveAgo={active}
presenceCurrentlyActive={user.currentlyActive}
name={name} title={user.userId} avatarJsx={avatarJsx} />
);
},
});

View file

@ -18,12 +18,12 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import WhoIsTyping from '../../../WhoIsTyping';
import * as WhoIsTyping from '../../../WhoIsTyping';
import Timer from '../../../utils/Timer';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import MemberAvatar from '../avatars/MemberAvatar';
module.exports = createReactClass({
export default createReactClass({
displayName: 'WhoIsTypingTile',
propTypes: {
@ -54,7 +54,7 @@ module.exports = createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
},
@ -213,7 +213,7 @@ module.exports = createReactClass({
}
return (
<li className="mx_WhoIsTypingTile">
<li className="mx_WhoIsTypingTile" aria-atomic="true">
<div className="mx_WhoIsTypingTile_avatars">
{ this._renderTypingIndicatorAvatars(usersTyping, this.props.whoIsTypingLimit) }
</div>