Merge branch 'develop' of https://github.com/matrix-org/matrix-react-sdk into resizable-appsdrawer

 Conflicts:
	res/css/views/rooms/_AppsDrawer.scss
This commit is contained in:
Michael Telatynski 2020-08-20 10:26:35 +01:00
commit ae65ed5c2e
1200 changed files with 50388 additions and 28790 deletions

View file

@ -21,7 +21,7 @@ import createReactClass from 'create-react-class';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import AppTile from '../elements/AppTile';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import * as ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
@ -138,7 +138,7 @@ export default createReactClass({
},
_launchManageIntegrations: function() {
if (SettingsStore.isFeatureEnabled("feature_many_integration_managers")) {
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll();
} else {
IntegrationManagers.sharedInstance().getPrimaryManager().open(this.props.room, 'add_integ');

View file

@ -15,8 +15,7 @@ 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, KeyboardEvent} from 'react';
import classNames from 'classnames';
import flatMap from 'lodash/flatMap';
import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter';
@ -54,7 +53,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
autocompleter: Autocompleter;
queryRequested: string;
debounceCompletionsRequest: NodeJS.Timeout;
containerRef: React.RefObject<HTMLDivElement>;
private containerRef = createRef<HTMLDivElement>();
constructor(props) {
super(props);
@ -78,8 +77,6 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
forceComplete: false,
};
this.containerRef = React.createRef();
}
componentDidMount() {
@ -256,14 +253,15 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
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.containerRef.current) {
const domNode = ReactDOM.findDOMNode(selectedCompletion);
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;
}
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`] as HTMLElement;
if (selectedCompletion) {
selectedCompletion.scrollIntoView({
behavior: "auto",
block: "nearest",
});
} else if (this.containerRef.current) {
this.containerRef.current.scrollTo({ top: 0 });
}
}

View file

@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from '../../../index';
import dis from "../../../dispatcher";
import dis from "../../../dispatcher/dispatcher";
import * as ObjectUtils from '../../../ObjectUtils';
import AppsDrawer from './AppsDrawer';
import { _t } from '../../../languageHandler';
@ -28,6 +28,7 @@ import classNames from 'classnames';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import CallView from "../voip/CallView";
export default createReactClass({
@ -103,7 +104,7 @@ export default createReactClass({
},
_rateLimitedUpdate: new RateLimitedFunc(function() {
if (SettingsStore.isFeatureEnabled("feature_state_counters")) {
if (SettingsStore.getValue("feature_state_counters")) {
this.setState({counters: this._computeCounters()});
}
}, 500),
@ -111,7 +112,7 @@ export default createReactClass({
_computeCounters: function() {
let counters = [];
if (this.props.room && SettingsStore.isFeatureEnabled("feature_state_counters")) {
if (this.props.room && SettingsStore.getValue("feature_state_counters")) {
const stateEvs = this.props.room.currentState.getStateEvents('re.jki.counter');
stateEvs.sort((a, b) => {
return a.getStateKey() < b.getStateKey();
@ -142,7 +143,6 @@ export default createReactClass({
},
render: function() {
const CallView = sdk.getComponent("voip.CallView");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
let fileDropTarget = null;
@ -206,7 +206,7 @@ export default createReactClass({
/>;
let stateViews = null;
if (this.state.counters && SettingsStore.isFeatureEnabled("feature_state_counters")) {
if (this.state.counters && SettingsStore.getValue("feature_state_counters")) {
let counters = [];
this.state.counters.forEach((counter, idx) => {

View file

@ -16,11 +16,13 @@ limitations under the License.
*/
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import React, {createRef, ClipboardEvent} from 'react';
import {Room} from 'matrix-js-sdk/src/models/room';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import EditorModel from '../../../editor/model';
import HistoryManager from '../../../editor/history';
import {setSelection} from '../../../editor/caret';
import {Caret, setSelection} from '../../../editor/caret';
import {
formatRangeAsQuote,
formatRangeAsCode,
@ -29,17 +31,21 @@ import {
} from '../../../editor/operations';
import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom';
import Autocomplete, {generateCompletionDomId} from '../rooms/Autocomplete';
import {autoCompleteCreator} from '../../../editor/parts';
import {getAutoCompleteCreator} from '../../../editor/parts';
import {parsePlainTextMessage} from '../../../editor/deserialize';
import {renderModel} from '../../../editor/render';
import {Room} from 'matrix-js-sdk';
import TypingStore from "../../../stores/TypingStore";
import SettingsStore from "../../../settings/SettingsStore";
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import * as sdk from '../../../index';
import {Key} from "../../../Keyboard";
import {EMOTICON_TO_EMOJI} from "../../../emoji";
import {CommandCategories, CommandMap, parseCommandString} from "../../../SlashCommands";
import Range from "../../../editor/range";
import MessageComposerFormatBar from "./MessageComposerFormatBar";
import DocumentOffset from "../../../editor/offset";
import {IDiff} from "../../../editor/diff";
import AutocompleteWrapperModel from "../../../editor/autocomplete";
import DocumentPosition from "../../../editor/position";
import {ICompletion} from "../../../autocomplete/Autocompleter";
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
@ -49,7 +55,7 @@ function ctrlShortcutLabel(key) {
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
}
function cloneSelection(selection) {
function cloneSelection(selection: Selection): Partial<Selection> {
return {
anchorNode: selection.anchorNode,
anchorOffset: selection.anchorOffset,
@ -61,7 +67,7 @@ function cloneSelection(selection) {
};
}
function selectionEquals(a: Selection, b: Selection): boolean {
function selectionEquals(a: Partial<Selection>, b: Selection): boolean {
return a.anchorNode === b.anchorNode &&
a.anchorOffset === b.anchorOffset &&
a.focusNode === b.focusNode &&
@ -71,44 +77,75 @@ function selectionEquals(a: Selection, b: Selection): boolean {
a.type === b.type;
}
export default class BasicMessageEditor extends React.Component {
static propTypes = {
onChange: PropTypes.func,
model: PropTypes.instanceOf(EditorModel).isRequired,
room: PropTypes.instanceOf(Room).isRequired,
placeholder: PropTypes.string,
label: PropTypes.string, // the aria label
initialCaret: PropTypes.object, // See DocumentPosition in editor/model.js
};
enum Formatting {
Bold = "bold",
Italics = "italics",
Strikethrough = "strikethrough",
Code = "code",
Quote = "quote",
}
interface IProps {
model: EditorModel;
room: Room;
placeholder?: string;
label?: string;
initialCaret?: DocumentOffset;
onChange();
onPaste?(event: ClipboardEvent<HTMLDivElement>, model: EditorModel): boolean;
}
interface IState {
showPillAvatar: boolean;
query?: string;
showVisualBell?: boolean;
autoComplete?: AutocompleteWrapperModel;
completionIndex?: number;
}
export default class BasicMessageEditor extends React.Component<IProps, IState> {
private editorRef = createRef<HTMLDivElement>();
private autocompleteRef = createRef<Autocomplete>();
private formatBarRef = createRef<typeof MessageComposerFormatBar>();
private modifiedFlag = false;
private isIMEComposing = false;
private hasTextSelected = false;
private _isCaretAtEnd: boolean;
private lastCaret: DocumentOffset;
private lastSelection: ReturnType<typeof cloneSelection>;
private readonly emoticonSettingHandle: string;
private readonly shouldShowPillAvatarSettingHandle: string;
private readonly historyManager = new HistoryManager();
constructor(props) {
super(props);
this.state = {
autoComplete: null,
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
};
this._editorRef = null;
this._autocompleteRef = null;
this._formatBarRef = null;
this._modifiedFlag = false;
this._isIMEComposing = false;
this._hasTextSelected = false;
this._emoticonSettingHandle = null;
this._shouldShowPillAvatarSettingHandle = null;
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
this.configureEmoticonAutoReplace);
this.configureEmoticonAutoReplace();
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
this.configureShouldShowPillAvatar);
}
componentDidUpdate(prevProps) {
public componentDidUpdate(prevProps: IProps) {
if (this.props.placeholder !== prevProps.placeholder && this.props.placeholder) {
const {isEmpty} = this.props.model;
if (isEmpty) {
this._showPlaceholder();
this.showPlaceholder();
} else {
this._hidePlaceholder();
this.hidePlaceholder();
}
}
}
_replaceEmoticon = (caretPosition, inputType, diff) => {
private replaceEmoticon = (caretPosition: DocumentPosition) => {
const {model} = this.props;
const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caretPosition,
@ -138,30 +175,30 @@ export default class BasicMessageEditor extends React.Component {
return range.replace([partCreator.plain(data.unicode + " ")]);
}
}
}
};
_updateEditorState = (selection, inputType, diff) => {
renderModel(this._editorRef, this.props.model);
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff) => {
renderModel(this.editorRef.current, this.props.model);
if (selection) { // set the caret/selection
try {
setSelection(this._editorRef, this.props.model, selection);
setSelection(this.editorRef.current, this.props.model, selection);
} catch (err) {
console.error(err);
}
// if caret selection is a range, take the end position
const position = selection.end || selection;
this._setLastCaretFromPosition(position);
const position = selection instanceof Range ? selection.end : selection;
this.setLastCaretFromPosition(position);
}
const {isEmpty} = this.props.model;
if (this.props.placeholder) {
if (isEmpty) {
this._showPlaceholder();
this.showPlaceholder();
} else {
this._hidePlaceholder();
this.hidePlaceholder();
}
}
if (isEmpty) {
this._formatBarRef.hide();
this.formatBarRef.current.hide();
}
this.setState({autoComplete: this.props.model.autoComplete});
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
@ -179,31 +216,33 @@ export default class BasicMessageEditor extends React.Component {
if (this.props.onChange) {
this.props.onChange();
}
};
private showPlaceholder() {
// escape single quotes
const placeholder = this.props.placeholder.replace(/'/g, '\\\'');
this.editorRef.current.style.setProperty("--placeholder", `'${placeholder}'`);
this.editorRef.current.classList.add("mx_BasicMessageComposer_inputEmpty");
}
_showPlaceholder() {
this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`);
this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty");
private hidePlaceholder() {
this.editorRef.current.classList.remove("mx_BasicMessageComposer_inputEmpty");
this.editorRef.current.style.removeProperty("--placeholder");
}
_hidePlaceholder() {
this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty");
this._editorRef.style.removeProperty("--placeholder");
}
_onCompositionStart = (event) => {
this._isIMEComposing = true;
private onCompositionStart = () => {
this.isIMEComposing = true;
// even if the model is empty, the composition text shouldn't be mixed with the placeholder
this._hidePlaceholder();
}
this.hidePlaceholder();
};
_onCompositionEnd = (event) => {
this._isIMEComposing = false;
private onCompositionEnd = () => {
this.isIMEComposing = false;
// some browsers (Chrome) don't fire an input event after ending a composition,
// so trigger a model update after the composition is done by calling the input handler.
// however, modifying the DOM (caused by the editor model update) from the compositionend handler seems
// to confuse the IME in Chrome, likely causing https://github.com/vector-im/riot-web/issues/10913 ,
// to confuse the IME in Chrome, likely causing https://github.com/vector-im/element-web/issues/10913 ,
// so we do it async
// however, doing this async seems to break things in Safari for some reason, so browser sniff.
@ -212,51 +251,57 @@ export default class BasicMessageEditor extends React.Component {
const isSafari = ua.includes('safari/') && !ua.includes('chrome/');
if (isSafari) {
this._onInput({inputType: "insertCompositionText"});
this.onInput({inputType: "insertCompositionText"});
} else {
Promise.resolve().then(() => {
this._onInput({inputType: "insertCompositionText"});
this.onInput({inputType: "insertCompositionText"});
});
}
}
};
isComposing(event) {
isComposing(event: React.KeyboardEvent) {
// checking the event.isComposing flag just in case any browser out there
// emits events related to the composition after compositionend
// has been fired
return !!(this._isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing));
return !!(this.isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing));
}
_onCutCopy = (event, type) => {
private onCutCopy = (event: ClipboardEvent, type: string) => {
const selection = document.getSelection();
const text = selection.toString();
if (text) {
const {model} = this.props;
const range = getRangeForSelection(this._editorRef, model, selection);
const range = getRangeForSelection(this.editorRef.current, model, selection);
const selectedParts = range.parts.map(p => p.serialize());
event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts));
event.clipboardData.setData("application/x-element-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;
this.modifiedFlag = true;
replaceRangeAndMoveCaret(range, []);
}
event.preventDefault();
}
}
};
_onCopy = (event) => {
this._onCutCopy(event, "copy");
}
private onCopy = (event: ClipboardEvent) => {
this.onCutCopy(event, "copy");
};
_onCut = (event) => {
this._onCutCopy(event, "cut");
}
private onCut = (event: ClipboardEvent) => {
this.onCutCopy(event, "cut");
};
private onPaste = (event: ClipboardEvent<HTMLDivElement>) => {
event.preventDefault(); // we always handle the paste ourselves
if (this.props.onPaste && this.props.onPaste(event, this.props.model)) {
// to prevent double handling, allow props.onPaste to skip internal onPaste
return true;
}
_onPaste = (event) => {
const {model} = this.props;
const {partCreator} = model;
const partsText = event.clipboardData.getData("application/x-riot-composer");
const partsText = event.clipboardData.getData("application/x-element-composer");
let parts;
if (partsText) {
const serializedTextParts = JSON.parse(partsText);
@ -266,29 +311,28 @@ export default class BasicMessageEditor extends React.Component {
const text = event.clipboardData.getData("text/plain");
parts = parsePlainTextMessage(text, partCreator);
}
this._modifiedFlag = true;
const range = getRangeForSelection(this._editorRef, model, document.getSelection());
this.modifiedFlag = true;
const range = getRangeForSelection(this.editorRef.current, model, document.getSelection());
replaceRangeAndMoveCaret(range, parts);
event.preventDefault();
}
};
_onInput = (event) => {
private onInput = (event: Partial<InputEvent>) => {
// ignore any input while doing IME compositions
if (this._isIMEComposing) {
if (this.isIMEComposing) {
return;
}
this._modifiedFlag = true;
this.modifiedFlag = true;
const sel = document.getSelection();
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
const {caret, text} = getCaretOffsetAndText(this.editorRef.current, sel);
this.props.model.update(text, event.inputType, caret);
}
};
_insertText(textToInsert, inputType = "insertText") {
private insertText(textToInsert: string, inputType = "insertText") {
const sel = document.getSelection();
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
const {caret, text} = getCaretOffsetAndText(this.editorRef.current, sel);
const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset);
caret.offset += textToInsert.length;
this._modifiedFlag = true;
this.modifiedFlag = true;
this.props.model.update(newText, inputType, caret);
}
@ -297,28 +341,28 @@ export default class BasicMessageEditor extends React.Component {
// we don't need to. But if the user is navigating the caret without input
// we need to recalculate it, to be able to know where to insert content after
// losing focus
_setLastCaretFromPosition(position) {
private setLastCaretFromPosition(position: DocumentPosition) {
const {model} = this.props;
this._isCaretAtEnd = position.isAtEnd(model);
this._lastCaret = position.asOffset(model);
this._lastSelection = cloneSelection(document.getSelection());
this.lastCaret = position.asOffset(model);
this.lastSelection = cloneSelection(document.getSelection());
}
_refreshLastCaretIfNeeded() {
private refreshLastCaretIfNeeded() {
// XXX: needed when going up and down in editing messages ... not sure why yet
// because the editors should stop doing this when when blurred ...
// maybe it's on focus and the _editorRef isn't available yet or something.
if (!this._editorRef) {
if (!this.editorRef.current) {
return;
}
const selection = document.getSelection();
if (!this._lastSelection || !selectionEquals(this._lastSelection, selection)) {
this._lastSelection = cloneSelection(selection);
const {caret, text} = getCaretOffsetAndText(this._editorRef, selection);
this._lastCaret = caret;
if (!this.lastSelection || !selectionEquals(this.lastSelection, selection)) {
this.lastSelection = cloneSelection(selection);
const {caret, text} = getCaretOffsetAndText(this.editorRef.current, selection);
this.lastCaret = caret;
this._isCaretAtEnd = caret.offset === text.length;
}
return this._lastCaret;
return this.lastCaret;
}
clearUndoHistory() {
@ -326,11 +370,11 @@ export default class BasicMessageEditor extends React.Component {
}
getCaret() {
return this._lastCaret;
return this.lastCaret;
}
isSelectionCollapsed() {
return !this._lastSelection || this._lastSelection.isCollapsed;
return !this.lastSelection || this.lastSelection.isCollapsed;
}
isCaretAtStart() {
@ -341,49 +385,51 @@ export default class BasicMessageEditor extends React.Component {
return this._isCaretAtEnd;
}
_onBlur = () => {
document.removeEventListener("selectionchange", this._onSelectionChange);
}
private onBlur = () => {
document.removeEventListener("selectionchange", this.onSelectionChange);
};
_onFocus = () => {
document.addEventListener("selectionchange", this._onSelectionChange);
private onFocus = () => {
document.addEventListener("selectionchange", this.onSelectionChange);
// force to recalculate
this._lastSelection = null;
this._refreshLastCaretIfNeeded();
}
this.lastSelection = null;
this.refreshLastCaretIfNeeded();
};
_onSelectionChange = () => {
this._refreshLastCaretIfNeeded();
private onSelectionChange = () => {
const {isEmpty} = this.props.model;
this.refreshLastCaretIfNeeded();
const selection = document.getSelection();
if (this._hasTextSelected && selection.isCollapsed) {
this._hasTextSelected = false;
if (this._formatBarRef) {
this._formatBarRef.hide();
if (this.hasTextSelected && selection.isCollapsed) {
this.hasTextSelected = false;
if (this.formatBarRef.current) {
this.formatBarRef.current.hide();
}
} else if (!selection.isCollapsed) {
this._hasTextSelected = true;
if (this._formatBarRef) {
} else if (!selection.isCollapsed && !isEmpty) {
this.hasTextSelected = true;
if (this.formatBarRef.current) {
const selectionRect = selection.getRangeAt(0).getBoundingClientRect();
this._formatBarRef.showAt(selectionRect);
this.formatBarRef.current.showAt(selectionRect);
}
}
}
};
_onKeyDown = (event) => {
private onKeyDown = (event: React.KeyboardEvent) => {
const model = this.props.model;
const modKey = IS_MAC ? event.metaKey : event.ctrlKey;
let handled = false;
// format bold
if (modKey && event.key === Key.B) {
this._onFormatAction("bold");
this.onFormatAction(Formatting.Bold);
handled = true;
// format italics
} else if (modKey && event.key === Key.I) {
this._onFormatAction("italics");
this.onFormatAction(Formatting.Italics);
handled = true;
// format quote
} else if (modKey && event.key === Key.GREATER_THAN) {
this._onFormatAction("quote");
this.onFormatAction(Formatting.Quote);
handled = true;
// redo
} else if ((!IS_MAC && modKey && event.key === Key.Y) ||
@ -406,18 +452,18 @@ export default class BasicMessageEditor extends React.Component {
handled = true;
// insert newline on Shift+Enter
} else if (event.key === Key.ENTER && (event.shiftKey || (IS_MAC && event.altKey))) {
this._insertText("\n");
this.insertText("\n");
handled = true;
// move selection to start of composer
} else if (modKey && event.key === Key.HOME && !event.shiftKey) {
setSelection(this._editorRef, model, {
setSelection(this.editorRef.current, 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, {
setSelection(this.editorRef.current, model, {
index: model.parts.length - 1,
offset: model.parts[model.parts.length - 1].text.length,
});
@ -457,19 +503,19 @@ export default class BasicMessageEditor extends React.Component {
return; // don't preventDefault on anything else
}
} else if (event.key === Key.TAB) {
this._tabCompleteName();
this.tabCompleteName(event);
handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
this._formatBarRef.hide();
this.formatBarRef.current.hide();
}
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
};
async _tabCompleteName() {
private async tabCompleteName(event: React.KeyboardEvent) {
try {
await new Promise(resolve => this.setState({showVisualBell: false}, resolve));
const {model} = this.props;
@ -492,7 +538,7 @@ export default class BasicMessageEditor extends React.Component {
// Don't try to do things with the autocomplete if there is none shown
if (model.autoComplete) {
await model.autoComplete.onTab();
await model.autoComplete.onTab(event);
if (!model.autoComplete.hasSelection()) {
this.setState({showVisualBell: true});
model.autoComplete.close();
@ -503,69 +549,61 @@ export default class BasicMessageEditor extends React.Component {
}
}
getEditableRootNode() {
return this._editorRef;
}
isModified() {
return this._modifiedFlag;
return this.modifiedFlag;
}
_onAutoCompleteConfirm = (completion) => {
private onAutoCompleteConfirm = (completion: ICompletion) => {
this.modifiedFlag = true;
this.props.model.autoComplete.onComponentConfirm(completion);
}
_onAutoCompleteSelectionChange = (completion, completionIndex) => {
this.props.model.autoComplete.onComponentSelectionChange(completion);
this.setState({completionIndex});
}
_configureEmoticonAutoReplace = () => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
this.props.model.setTransformCallback(shouldReplace ? this._replaceEmoticon : null);
};
_configureShouldShowPillAvatar = () => {
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => {
this.modifiedFlag = true;
this.props.model.autoComplete.onComponentSelectionChange(completion);
this.setState({completionIndex});
};
private configureEmoticonAutoReplace = () => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
};
private 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);
document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true);
this.editorRef.current.removeEventListener("compositionstart", this.onCompositionStart, true);
this.editorRef.current.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._configureEmoticonAutoReplace();
this._shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
this._configureShouldShowPillAvatar);
model.setUpdateCallback(this.updateEditorState);
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
partCreator.setAutoCompleteCreator(autoCompleteCreator(
() => this._autocompleteRef,
partCreator.setAutoCompleteCreator(getAutoCompleteCreator(
() => this.autocompleteRef.current,
query => new Promise(resolve => this.setState({query}, resolve)),
));
this.historyManager = new HistoryManager(partCreator);
// initial render of model
this._updateEditorState(this._getInitialCaretPosition());
this.updateEditorState(this.getInitialCaretPosition());
// attach input listener by hand so React doesn't proxy the events,
// as the proxied event doesn't support inputType, which we need.
this._editorRef.addEventListener("input", this._onInput, true);
this._editorRef.addEventListener("compositionstart", this._onCompositionStart, true);
this._editorRef.addEventListener("compositionend", this._onCompositionEnd, true);
this._editorRef.focus();
this.editorRef.current.addEventListener("input", this.onInput, true);
this.editorRef.current.addEventListener("compositionstart", this.onCompositionStart, true);
this.editorRef.current.addEventListener("compositionend", this.onCompositionEnd, true);
this.editorRef.current.focus();
}
_getInitialCaretPosition() {
private getInitialCaretPosition() {
let caretPosition;
if (this.props.initialCaret) {
// if restoring state from a previous editor,
@ -579,34 +617,34 @@ export default class BasicMessageEditor extends React.Component {
return caretPosition;
}
_onFormatAction = (action) => {
private onFormatAction = (action: Formatting) => {
const range = getRangeForSelection(
this._editorRef,
this.editorRef.current,
this.props.model,
document.getSelection());
if (range.length === 0) {
return;
}
this.historyManager.ensureLastChangesPushed(this.props.model);
this._modifiedFlag = true;
this.modifiedFlag = true;
switch (action) {
case "bold":
case Formatting.Bold:
toggleInlineFormat(range, "**");
break;
case "italics":
case Formatting.Italics:
toggleInlineFormat(range, "_");
break;
case "strikethrough":
case Formatting.Strikethrough:
toggleInlineFormat(range, "<del>", "</del>");
break;
case "code":
case Formatting.Code:
formatRangeAsCode(range);
break;
case "quote":
case Formatting.Quote:
formatRangeAsQuote(range);
break;
}
}
};
render() {
let autoComplete;
@ -615,10 +653,10 @@ export default class BasicMessageEditor extends React.Component {
const queryLen = query.length;
autoComplete = (<div className="mx_BasicMessageComposer_AutoCompleteWrapper">
<Autocomplete
ref={ref => this._autocompleteRef = ref}
ref={this.autocompleteRef}
query={query}
onConfirm={this._onAutoCompleteConfirm}
onSelectionChange={this._onAutoCompleteSelectionChange}
onConfirm={this.onAutoCompleteConfirm}
onSelectionChange={this.onAutoCompleteSelectionChange}
selection={{beginning: true, end: queryLen, start: queryLen}}
room={this.props.room}
/>
@ -631,7 +669,6 @@ export default class BasicMessageEditor extends React.Component {
"mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar,
});
const MessageComposerFormatBar = sdk.getComponent('rooms.MessageComposerFormatBar');
const shortcuts = {
bold: ctrlShortcutLabel("B"),
italics: ctrlShortcutLabel("I"),
@ -642,18 +679,18 @@ export default class BasicMessageEditor extends React.Component {
return (<div className={wrapperClasses}>
{ autoComplete }
<MessageComposerFormatBar ref={ref => this._formatBarRef = ref} onAction={this._onFormatAction} shortcuts={shortcuts} />
<MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} />
<div
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}
tabIndex={0}
onBlur={this.onBlur}
onFocus={this.onFocus}
onCopy={this.onCopy}
onCut={this.onCut}
onPaste={this.onPaste}
onKeyDown={this.onKeyDown}
ref={this.editorRef}
aria-label={this.props.label}
role="textbox"
aria-multiline="true"
@ -667,6 +704,6 @@ export default class BasicMessageEditor extends React.Component {
}
focus() {
this._editorRef.focus();
this.editorRef.current.focus();
}
}

View file

@ -20,7 +20,6 @@ import PropTypes from "prop-types";
import classNames from 'classnames';
import {_t, _td} from '../../../languageHandler';
import {useSettingValue} from "../../../hooks/useSettings";
import AccessibleButton from "../elements/AccessibleButton";
import Tooltip from "../elements/Tooltip";
@ -29,6 +28,7 @@ export const E2E_STATE = {
WARNING: "warning",
UNKNOWN: "unknown",
NORMAL: "normal",
UNAUTHENTICATED: "unauthenticated",
};
const crossSigningUserTitles = {
@ -42,35 +42,22 @@ const crossSigningRoomTitles = {
[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 E2EIcon = ({isUser, status, className, size, onClick, hideTooltip, bordered}) => {
const [hover, setHover] = useState(false);
const classes = classNames({
mx_E2EIcon: true,
mx_E2EIcon_bordered: bordered,
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 = useSettingValue("feature_cross_signing");
if (crossSigning && isUser) {
if (isUser) {
e2eTitle = crossSigningUserTitles[status];
} else if (crossSigning && !isUser) {
} else {
e2eTitle = crossSigningRoomTitles[status];
} else if (!crossSigning && isUser) {
e2eTitle = legacyUserTitles[status];
} else if (!crossSigning && !isUser) {
e2eTitle = legacyRoomTitles[status];
}
let style;
@ -79,7 +66,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip}) => {
}
const onMouseOver = () => setHover(true);
const onMouseOut = () => setHover(false);
const onMouseLeave = () => setHover(false);
let tip;
if (hover && !hideTooltip) {
@ -91,7 +78,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip}) => {
<AccessibleButton
onClick={onClick}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onMouseLeave={onMouseLeave}
className={classes}
style={style}
>
@ -100,7 +87,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip}) => {
);
}
return <div onMouseOver={onMouseOver} onMouseOut={onMouseOut} className={classes} style={style}>
return <div onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} className={classes} style={style}>
{ tip }
</div>;
};

View file

@ -18,7 +18,7 @@ import React from 'react';
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';
@ -31,6 +31,7 @@ import {EventStatus} from 'matrix-js-sdk';
import BasicMessageComposer from "./BasicMessageComposer";
import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions";
function _isReply(mxEvent) {
const relatesTo = mxEvent.getContent()["m.relates_to"];
@ -157,7 +158,7 @@ export default class EditMessageComposer extends React.Component {
dis.dispatch({action: 'edit_event', event: nextEvent});
} else {
dis.dispatch({action: 'edit_event', event: null});
dis.dispatch({action: 'focus_composer'});
dis.fire(Action.FocusComposer);
}
event.preventDefault();
}
@ -165,7 +166,7 @@ export default class EditMessageComposer extends React.Component {
_cancelEdit = () => {
dis.dispatch({action: "edit_event", event: null});
dis.dispatch({action: 'focus_composer'});
dis.fire(Action.FocusComposer);
}
_isContentModified(newContent) {
@ -190,11 +191,12 @@ export default class EditMessageComposer extends React.Component {
const roomId = editedEvent.getRoomId();
this._cancelPreviousPendingEdit();
this.context.sendMessage(roomId, editContent);
dis.dispatch({action: "message_sent"});
}
// close the event editing and focus composer
dis.dispatch({action: "edit_event", event: null});
dis.dispatch({action: 'focus_composer'});
dis.fire(Action.FocusComposer);
};
_cancelPreviousPendingEdit() {

View file

@ -170,7 +170,7 @@ const EntityTile = createReactClass({
let e2eIcon;
const { e2eStatus } = this.props;
if (e2eStatus) {
e2eIcon = <E2EIcon status={e2eStatus} isUser={true} />;
e2eIcon = <E2EIcon status={e2eStatus} isUser={true} bordered={true} />;
}
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');

View file

@ -25,7 +25,7 @@ import classNames from "classnames";
import { _t, _td } from '../../../languageHandler';
import * as TextForEvent from "../../../TextForEvent";
import * as sdk from "../../../index";
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import {EventStatus} from 'matrix-js-sdk';
import {formatTime} from "../../../DateUtils";
@ -34,7 +34,7 @@ import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
import * as ObjectUtils from "../../../ObjectUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon";
import toRem from "../../../utils/rem";
import {toRem} from "../../../utils/units";
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
@ -60,7 +60,7 @@ const stateEventTileTypes = {
'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)
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
'im.vector.modular.widgets': 'messages.TextualEvent',
'm.room.tombstone': 'messages.TextualEvent',
'm.room.join_rules': 'messages.TextualEvent',
@ -104,7 +104,7 @@ export function getHandlerTile(ev) {
// 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")) {
if (type === "m.key.verification.cancel" || type === "m.key.verification.done") {
const MKeyVerificationConclusion = sdk.getComponent("messages.MKeyVerificationConclusion");
if (!MKeyVerificationConclusion.prototype._shouldRender.call(null, ev, ev.request)) {
return;
@ -206,6 +206,9 @@ export default createReactClass({
// whether to show reactions for this event
showReactions: PropTypes.bool,
// whether to use the irc layout
useIRCLayout: PropTypes.bool,
},
getDefaultProps: function() {
@ -310,44 +313,52 @@ export default createReactClass({
return;
}
// 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;
}
const encryptionInfo = this.context.getEventEncryptionInfo(mxEvent);
const senderId = mxEvent.getSender();
const userTrust = this.context.checkUserTrust(senderId);
// 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")) {
if (encryptionInfo.mismatchedSender) {
// something definitely wrong is going on here
this.setState({
verified: E2E_STATE.WARNING,
}, this.props.onHeightChanged);
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
if (!this.context.checkUserTrust(mxEvent.getSender()).isCrossSigningVerified()) {
if (!userTrust.isCrossSigningVerified()) {
// user is not verified, so default to everything is normal
this.setState({
verified: E2E_STATE.NORMAL,
}, this.props.onHeightChanged);
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
const eventSenderTrust = await this.context.checkEventSenderTrust(mxEvent);
const eventSenderTrust = encryptionInfo.sender && this.context.checkDeviceTrust(
senderId, encryptionInfo.sender.deviceId,
);
if (!eventSenderTrust) {
this.setState({
verified: E2E_STATE.UNKNOWN,
}, this.props.onHeightChanged); // Decryption may have cause a change in size
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
if (!eventSenderTrust.isVerified()) {
this.setState({
verified: E2E_STATE.WARNING,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
if (!encryptionInfo.authenticated) {
this.setState({
verified: E2E_STATE.UNAUTHENTICATED,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
this.setState({
verified: eventSenderTrust.isVerified() ? E2E_STATE.VERIFIED : E2E_STATE.WARNING,
verified: E2E_STATE.VERIFIED,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
},
@ -400,7 +411,7 @@ export default createReactClass({
},
shouldHighlight: function() {
const actions = this.context.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
@ -508,7 +519,7 @@ export default createReactClass({
onPermalinkClicked: function(e) {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Riot when clicked.
// matrix.to, but also for it to enable routing within Element when clicked.
e.preventDefault();
dis.dispatch({
action: 'view_room',
@ -532,6 +543,8 @@ export default createReactClass({
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.UNAUTHENTICATED) {
return (<E2ePadlockUnauthenticated />);
} else if (this.state.verified === E2E_STATE.UNKNOWN) {
return (<E2ePadlockUnknown />);
} else {
@ -582,11 +595,11 @@ export default createReactClass({
}
const eventId = this.props.mxEvent.getId();
if (!eventId) {
// XXX: Temporary diagnostic logging for https://github.com/vector-im/riot-web/issues/11120
// XXX: Temporary diagnostic logging for https://github.com/vector-im/element-web/issues/11120
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");
console.trace("Stacktrace for https://github.com/vector-im/element-web/issues/11120");
}
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
},
@ -670,9 +683,11 @@ export default createReactClass({
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN,
mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_redacted: isRedacted,
});
// If the tile is in the Sending state, don't speak the message.
const ariaLive = (this.props.eventSendStatus !== null) ? 'off' : undefined;
let permalink = "#";
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
@ -696,6 +711,9 @@ export default 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;
@ -787,11 +805,24 @@ export default 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.getRoom(this.props.mxEvent.getRoomId());
return (
<div className={classes}>
<div className={classes} aria-live={ariaLive} aria-atomic="true">
<div className="mx_EventTile_roomName">
<a href={permalink} onClick={this.onPermalinkClicked}>
{ room ? room.name : '' }
@ -817,7 +848,7 @@ export default createReactClass({
}
case 'file_grid': {
return (
<div className={classes}>
<div className={classes} aria-live={ariaLive} aria-atomic="true">
<div className="mx_EventTile_line">
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
@ -853,14 +884,14 @@ export default createReactClass({
);
}
return (
<div className={classes}>
<div className={classes} aria-live={ariaLive} aria-atomic="true">
{ 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}
@ -878,23 +909,21 @@ export default 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}>
<div className={classes} tabIndex={-1} aria-live={ariaLive} aria-atomic="true">
{ 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}
@ -969,6 +998,12 @@ function E2ePadlockUnknown(props) {
);
}
function E2ePadlockUnauthenticated(props) {
return (
<E2ePadlock title={_t("The authenticity of this encrypted message can't be guaranteed on this device.")} icon="unauthenticated" {...props} />
);
}
class E2ePadlock extends React.Component {
static propTypes = {
icon: PropTypes.string.isRequired,

View file

@ -19,7 +19,7 @@ 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';

View file

@ -1,53 +0,0 @@
/*
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

@ -16,13 +16,18 @@ limitations under the License.
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames';
export default (props) => {
const className = classNames({
'mx_JumpToBottomButton': true,
'mx_JumpToBottomButton_highlight': props.highlight,
});
let badge;
if (props.numUnreadMessages) {
badge = (<div className="mx_JumpToBottomButton_badge">{props.numUnreadMessages}</div>);
}
return (<div className="mx_JumpToBottomButton">
return (<div className={className}>
<AccessibleButton className="mx_JumpToBottomButton_scrollDown"
title={_t("Scroll to most recent messages")}
onClick={props.onScrollToBottomClick}>

View file

@ -134,7 +134,7 @@ export default createReactClass({
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_LinkPreviewWidget" >
<div className="mx_LinkPreviewWidget">
{ img }
<div className="mx_LinkPreviewWidget_caption">
<div className="mx_LinkPreviewWidget_title"><a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a></div>

View file

@ -1,59 +0,0 @@
/*
Copyright 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 * 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(),
mx_MemberDeviceInfo_icon_verified: this.props.device.isVerified(),
mx_MemberDeviceInfo_icon_unverified: this.props.device.isUnverified(),
});
const indicator = (<div className={iconClasses} />);
const deviceName = (this.props.device.ambiguous || this.props.showDeviceId) ?
(this.props.device.getDisplayName() ? this.props.device.getDisplayName() : "") + " (" + this.props.device.deviceId + ")" :
this.props.device.getDisplayName();
// add the deviceId as a titletext to help with debugging
return (
<div className="mx_MemberDeviceInfo"
title={_t("device id: ") + this.props.device.deviceId} >
{ indicator }
<div className="mx_MemberDeviceInfo_deviceInfo">
<div className="mx_MemberDeviceInfo_deviceId">
{ deviceName }
</div>
</div>
<DeviceVerifyButtons userId={this.props.userId} device={this.props.device} />
</div>
);
}
}
MemberDeviceInfo.displayName = 'MemberDeviceInfo';
MemberDeviceInfo.propTypes = {
userId: PropTypes.string.isRequired,
device: PropTypes.object.isRequired,
};

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,7 @@ 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";
import rate_limited_func from "../../../ratelimitedfunc";

View file

@ -20,9 +20,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import * as sdk from "../../../index";
import dis from "../../../dispatcher";
import dis from "../../../dispatcher/dispatcher";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import {Action} from "../../../dispatcher/actions";
export default createReactClass({
displayName: 'MemberTile',
@ -49,28 +50,26 @@ export default createReactClass({
componentDidMount() {
const cli = MatrixClientPeg.get();
if (SettingsStore.isFeatureEnabled("feature_custom_status")) {
if (SettingsStore.getValue("feature_custom_status")) {
const { user } = this.props.member;
if (user) {
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
}
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);
}
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);
}
}
},
@ -185,7 +184,7 @@ export default createReactClass({
onClick: function(e) {
dis.dispatch({
action: 'view_user',
action: Action.ViewUser,
member: this.props.member,
});
},
@ -210,7 +209,7 @@ export default createReactClass({
const presenceState = member.user ? member.user.presence : null;
let statusMessage = null;
if (member.user && SettingsStore.isFeatureEnabled("feature_custom_status")) {
if (member.user && SettingsStore.getValue("feature_custom_status")) {
statusMessage = this.state.statusMessage;
}

View file

@ -15,18 +15,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import dis from '../../../dispatcher';
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, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview";
function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@ -40,7 +44,6 @@ ComposerAvatar.propTypes = {
};
function CallButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const onVoiceCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
@ -49,10 +52,11 @@ function CallButton(props) {
});
};
return (<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
onClick={onVoiceCallClick}
title={_t('Voice call')}
/>);
return (<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_voicecall"
onClick={onVoiceCallClick}
title={_t('Voice call')}
/>);
}
CallButton.propTypes = {
@ -60,7 +64,6 @@ CallButton.propTypes = {
};
function VideoCallButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const onCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
@ -69,7 +72,8 @@ function VideoCallButton(props) {
});
};
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_videocall"
return <AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_videocall"
onClick={onCallClick}
title={_t('Video call')}
/>;
@ -103,6 +107,43 @@ HangupButton.propTypes = {
roomId: PropTypes.string.isRequired,
};
const EmojiButton = ({addEmoji}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();
const EmojiPicker = sdk.getComponent('emojipicker.EmojiPicker');
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} catchTab={false}>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>;
}
const className = classNames(
"mx_MessageComposer_button",
"mx_MessageComposer_emoji",
{
"mx_MessageComposer_button_highlight": menuDisplayed,
},
);
// TODO: replace ContextMenuTooltipButton with a unified representation of
// the header buttons and the right panel buttons
return <React.Fragment>
<ContextMenuTooltipButton
className={className}
onClick={openMenu}
isExpanded={menuDisplayed}
title={_t('Emoji picker')}
inputRef={button}
>
</ContextMenuTooltipButton>
{ contextMenu }
</React.Fragment>;
};
class UploadButton extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
@ -114,8 +155,19 @@ class UploadButton extends React.Component {
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'});
@ -128,7 +180,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]);
@ -147,9 +199,9 @@ class UploadButton extends React.Component {
render() {
const uploadInputStyle = {display: 'none'};
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_upload"
<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_upload"
onClick={this.onUploadClick}
title={_t('Upload file')}
>
@ -160,7 +212,7 @@ class UploadButton extends React.Component {
multiple
onChange={this.onUploadFileInputChange}
/>
</AccessibleButton>
</AccessibleTooltipButton>
);
}
}
@ -173,7 +225,7 @@ export default class MessageComposer extends React.Component {
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this);
this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
this._dispatcherRef = null;
this.state = {
isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
tombstone: this._getRoomTombstone(),
@ -182,7 +234,20 @@ export default class MessageComposer extends React.Component {
};
}
onAction = (payload) => {
if (payload.action === 'reply_to_event') {
// add a timeout for the reply preview to be rendered, so
// that the ScrollPanel listening to the resizeNotifier can
// correctly measure it's new height and scroll down to keep
// at the bottom if it already is
setTimeout(() => {
this.props.resizeNotifier.notifyTimelineHeightChanged();
}, 100);
}
};
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._waitForOwnMember();
@ -211,6 +276,7 @@ export default class MessageComposer extends React.Component {
if (this._roomStoreToken) {
this._roomStoreToken.remove();
}
dis.unregister(this.dispatcherRef);
}
_onRoomStateEvents(ev, state) {
@ -270,37 +336,28 @@ export default class MessageComposer extends React.Component {
}
renderPlaceholderText() {
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…');
}
if (this.state.isQuoting) {
if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
} else {
if (this.props.e2eStatus) {
return _t('Send an encrypted message…');
} else {
return _t('Send a message…');
}
return _t('Send a reply…');
}
} else {
if (this.state.isQuoting) {
if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
} else {
return _t('Send a reply (unencrypted)…');
}
if (this.props.e2eStatus) {
return _t('Send an encrypted message…');
} else {
if (this.props.e2eStatus) {
return _t('Send an encrypted message…');
} else {
return _t('Send a message (unencrypted)…');
}
return _t('Send a message…');
}
}
}
addEmoji(emoji) {
dis.dispatch({
action: "insert_emoji",
emoji,
});
}
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
@ -323,9 +380,11 @@ export default class MessageComposer extends React.Component {
key="controls_input"
room={this.props.room}
placeholder={this.renderPlaceholderText()}
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.props.permalinkCreator} />,
<Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} />,
<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />,
);
if (this.state.showCallButtons) {
@ -370,8 +429,9 @@ export default class MessageComposer extends React.Component {
}
return (
<div className="mx_MessageComposer">
<div className="mx_MessageComposer mx_GroupLayout">
<div className="mx_MessageComposer_wrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
<div className="mx_MessageComposer_row">
{ controls }
</div>

View file

@ -17,9 +17,8 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import classNames from 'classnames';
import AccessibleButton from "../elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
export default class MessageComposerFormatBar extends React.PureComponent {
static propTypes = {
@ -68,28 +67,28 @@ class FormatButton extends React.PureComponent {
};
render() {
const InteractiveTooltip = sdk.getComponent('elements.InteractiveTooltip');
const className = `mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIcon${this.props.icon}`;
let shortcut;
if (this.props.shortcut) {
shortcut = <div className="mx_MessageComposerFormatBar_tooltipShortcut">{this.props.shortcut}</div>;
}
const tooltipContent = (
<div className="mx_MessageComposerFormatBar_buttonTooltip">
<div>{this.props.label}</div>
const tooltip = <div>
<div className="mx_Tooltip_title">
{this.props.label}
</div>
<div className="mx_Tooltip_sub">
{shortcut}
</div>
);
</div>;
return (
<InteractiveTooltip content={tooltipContent} forceHidden={!this.props.visible}>
<AccessibleButton
as="span"
role="button"
onClick={this.props.onClick}
aria-label={this.props.label}
className={className} />
</InteractiveTooltip>
<AccessibleTooltipButton
as="span"
role="button"
onClick={this.props.onClick}
title={this.props.label}
tooltip={tooltip}
className={className} />
);
}
}

View file

@ -0,0 +1,137 @@
/*
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 classNames from "classnames";
import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common";
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
interface IProps {
notification: NotificationState;
/**
* If true, the badge will show a count if at all possible. This is typically
* used to override the user's preference for things like room sublists.
*/
forceCount: boolean;
/**
* The room ID, if any, the badge represents.
*/
roomId?: string;
}
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
/**
* If specified will return an AccessibleButton instead of a div.
*/
onClick?(ev: React.MouseEvent);
}
interface IState {
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
}
export default class NotificationBadge extends React.PureComponent<XOR<IProps, IClickableProps>, IState> {
private countWatcherRef: string;
constructor(props: IProps) {
super(props);
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.state = {
showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId),
};
this.countWatcherRef = SettingsStore.watchSetting(
"Notifications.alwaysShowBadgeCounts", this.roomId,
this.countPreferenceChanged,
);
}
private get roomId(): string {
// We should convert this to null for safety with the SettingsStore
return this.props.roomId || null;
}
public componentWillUnmount() {
SettingsStore.unwatchSetting(this.countWatcherRef);
this.props.notification.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
}
public componentDidUpdate(prevProps: Readonly<IProps>) {
if (prevProps.notification) {
prevProps.notification.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
}
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
}
private countPreferenceChanged = () => {
this.setState({showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId)});
};
private onNotificationUpdate = () => {
this.forceUpdate(); // notification state changed - update
};
public render(): React.ReactElement {
const {notification, forceCount, roomId, onClick, ...props} = this.props;
// Don't show a badge if we don't need to
if (notification.isIdle) return null;
// TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/element-web/issues/14261
// As of writing, that is "if red, show count always" and "optionally show counts instead of dots".
// See git diff for what that boolean state looks like.
// XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
const hasAnySymbol = notification.symbol || notification.count > 0;
let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount;
if (forceCount) {
isEmptyBadge = false;
if (!notification.hasUnreadCount) return null; // Can't render a badge
}
let symbol = notification.symbol || formatMinimalBadgeCount(notification.count);
if (isEmptyBadge) symbol = "";
const classes = classNames({
'mx_NotificationBadge': true,
'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount,
'mx_NotificationBadge_highlighted': notification.hasMentions,
'mx_NotificationBadge_dot': isEmptyBadge,
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
'mx_NotificationBadge_3char': symbol.length > 2,
});
if (onClick) {
return (
<AccessibleButton {...props} className={classes} onClick={onClick}>
<span className="mx_NotificationBadge_count">{symbol}</span>
</AccessibleButton>
);
}
return (
<div className={classes}>
<span className="mx_NotificationBadge_count">{symbol}</span>
</div>
);
}
}

View file

@ -18,7 +18,7 @@ import React from "react";
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import dis from "../../../dispatcher";
import dis from "../../../dispatcher/dispatcher";
import AccessibleButton from "../elements/AccessibleButton";
import MessageEvent from "../messages/MessageEvent";
import MemberAvatar from "../avatars/MemberAvatar";

View file

@ -23,7 +23,7 @@ import { _t } from '../../../languageHandler';
import {formatDate} from '../../../DateUtils';
import Velociraptor from "../../../Velociraptor";
import * as sdk from "../../../index";
import toRem from "../../../utils/rem";
import {toPx} from "../../../utils/units";
let bounce = false;
try {
@ -149,7 +149,7 @@ export default createReactClass({
// start at the old height and in the old h pos
startStyles.push({ top: startTopOffset+"px",
left: toRem(oldInfo.left) });
left: toPx(oldInfo.left) });
const reorderTransitionOpts = {
duration: 100,
@ -182,7 +182,7 @@ export default createReactClass({
}
const style = {
left: toRem(this.props.leftOffset),
left: toPx(this.props.leftOffset),
top: '0px',
visibility: this.props.hidden ? 'hidden' : 'visible',
};

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from 'react';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore';
@ -73,7 +73,7 @@ export default class ReplyPreview extends React.Component {
return <div className="mx_ReplyPreview">
<div className="mx_ReplyPreview_section">
<div className="mx_ReplyPreview_header mx_ReplyPreview_title">
{ '💬 ' + _t('Replying') }
{ _t('Replying') }
</div>
<div className="mx_ReplyPreview_header mx_ReplyPreview_cancel">
<img className="mx_filterFlipColor" src={require("../../../../res/img/cancel.svg")} width="18" height="18"

View file

@ -1,394 +0,0 @@
/*
Copyright 2019 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 dis from "../../../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 * as sdk from "../../../index";
import Analytics from "../../../Analytics";
import * as RoomNotifs from '../../../RoomNotifs';
import * as FormattingUtils from "../../../utils/FormattingUtils";
import DMRoomMap from "../../../utils/DMRoomMap";
import {_t} from "../../../languageHandler";
const MAX_ROOMS = 20;
const MIN_ROOMS_BEFORE_ENABLED = 10;
// The threshold time in milliseconds to wait for an autojoined room to show up.
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90 seconds
export default class RoomBreadcrumbs extends React.Component {
constructor(props) {
super(props);
this.state = {rooms: [], enabled: false};
this.onAction = this.onAction.bind(this);
this._dispatcherRef = null;
// The room IDs we're waiting to come down the Room handler and when we
// started waiting for them. Used to track a room over an upgrade/autojoin.
this._waitingRoomQueue = [/* { roomId, addedTs } */];
this._scroller = createRef();
}
// 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");
this._loadRoomIds(storedRooms || []);
this._settingWatchRef = SettingsStore.watchSetting("breadcrumb_rooms", null, this.onBreadcrumbsChanged);
this.setState({enabled: this._shouldEnable()});
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted);
MatrixClientPeg.get().on("Room", this.onRoom);
}
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
SettingsStore.unwatchSetting(this._settingWatchRef);
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("Room.myMembership", this.onMyMembership);
client.removeListener("Room.receipt", this.onRoomReceipt);
client.removeListener("Room.timeline", this.onRoomTimeline);
client.removeListener("Event.decrypted", this.onEventDecrypted);
client.removeListener("Room", this.onRoom);
}
}
componentDidUpdate() {
const rooms = this.state.rooms.slice();
if (rooms.length) {
const roomModel = rooms[0];
if (!roomModel.animated) {
roomModel.animated = true;
setTimeout(() => this.setState({rooms}), 0);
}
}
}
onAction(payload) {
switch (payload.action) {
case 'view_room':
if (payload.auto_join && !MatrixClientPeg.get().getRoom(payload.room_id)) {
// Queue the room instead of pushing it immediately - we're probably just waiting
// for a join to complete (ie: joining the upgraded room).
this._waitingRoomQueue.push({roomId: payload.room_id, addedTs: (new Date).getTime()});
break;
}
this._appendRoomId(payload.room_id);
break;
// XXX: slight hack in order to zero the notification count when a room
// is read. Copied from RoomTile
case 'on_room_read': {
const room = MatrixClientPeg.get().getRoom(payload.roomId);
this._calculateRoomBadges(room, /*zero=*/true);
break;
}
}
}
onMyMembership = (room, membership) => {
if (membership === "leave" || membership === "ban") {
const rooms = this.state.rooms.slice();
const roomState = rooms.find((r) => r.room.roomId === room.roomId);
if (roomState) {
roomState.left = true;
this.setState({rooms});
}
}
this.onRoomMembershipChanged();
};
onRoomReceipt = (event, room) => {
if (this.state.rooms.map(r => r.room.roomId).includes(room.roomId)) {
this._calculateRoomBadges(room);
}
};
onRoomTimeline = (event, room) => {
if (!room) return; // Can be null for the notification timeline, etc.
if (this.state.rooms.map(r => r.room.roomId).includes(room.roomId)) {
this._calculateRoomBadges(room);
}
};
onEventDecrypted = (event) => {
if (this.state.rooms.map(r => r.room.roomId).includes(event.getRoomId())) {
this._calculateRoomBadges(MatrixClientPeg.get().getRoom(event.getRoomId()));
}
};
onBreadcrumbsChanged = (settingName, roomId, level, valueAtLevel, value) => {
if (!value) return;
const currentState = this.state.rooms.map((r) => r.room.roomId);
if (currentState.length === value.length) {
let changed = false;
for (let i = 0; i < currentState.length; i++) {
if (currentState[i] !== value[i]) {
changed = true;
break;
}
}
if (!changed) return;
}
this._loadRoomIds(value);
};
onRoomMembershipChanged = () => {
if (!this.state.enabled && this._shouldEnable()) {
this.setState({enabled: true});
}
};
onRoom = (room) => {
// Always check for membership changes when we see new rooms
this.onRoomMembershipChanged();
const waitingRoom = this._waitingRoomQueue.find(r => r.roomId === room.roomId);
if (!waitingRoom) return;
this._waitingRoomQueue.splice(this._waitingRoomQueue.indexOf(waitingRoom), 1);
const now = (new Date()).getTime();
if ((now - waitingRoom.addedTs) > AUTOJOIN_WAIT_THRESHOLD_MS) return; // Too long ago.
this._appendRoomId(room.roomId); // add the room we've been waiting for
};
_shouldEnable() {
const client = MatrixClientPeg.get();
const joinedRoomCount = client.getRooms().reduce((count, r) => {
return count + (r.getMyMembership() === "join" ? 1 : 0);
}, 0);
return joinedRoomCount >= MIN_ROOMS_BEFORE_ENABLED;
}
_loadRoomIds(roomIds) {
if (!roomIds || roomIds.length <= 0) return; // Skip updates with no rooms
// If we're here, the list changed.
const rooms = roomIds.map((r) => MatrixClientPeg.get().getRoom(r)).filter((r) => r).map((r) => {
const badges = this._calculateBadgesForRoom(r) || {};
return {
room: r,
animated: false,
...badges,
};
});
this.setState({
rooms: rooms,
});
}
_calculateBadgesForRoom(room, zero=false) {
if (!room) return null;
// Reset the notification variables for simplicity
const roomModel = {
redBadge: false,
formattedCount: "0",
showCount: false,
};
if (zero) return roomModel;
const notifState = RoomNotifs.getRoomNotifsState(room.roomId);
if (RoomNotifs.MENTION_BADGE_STATES.includes(notifState)) {
const highlightNotifs = RoomNotifs.getUnreadNotificationCount(room, 'highlight');
const unreadNotifs = RoomNotifs.getUnreadNotificationCount(room);
const redBadge = highlightNotifs > 0;
const greyBadge = redBadge || (unreadNotifs > 0 && RoomNotifs.BADGE_STATES.includes(notifState));
if (redBadge || greyBadge) {
const notifCount = redBadge ? highlightNotifs : unreadNotifs;
const limitedCount = FormattingUtils.formatCount(notifCount);
roomModel.redBadge = redBadge;
roomModel.formattedCount = limitedCount;
roomModel.showCount = true;
}
}
return roomModel;
}
_calculateRoomBadges(room, zero=false) {
if (!room) return;
const rooms = this.state.rooms.slice();
const roomModel = rooms.find((r) => r.room.roomId === room.roomId);
if (!roomModel) return; // No applicable room, so don't do math on it
const badges = this._calculateBadgesForRoom(room, zero);
if (!badges) return; // No badges for some reason
Object.assign(roomModel, badges);
this.setState({rooms});
}
_appendRoomId(roomId) {
let room = MatrixClientPeg.get().getRoom(roomId);
if (!room) return;
const rooms = this.state.rooms.slice();
// If the room is upgraded, use that room instead. We'll also splice out
// any children of the room.
const history = MatrixClientPeg.get().getRoomUpgradeHistory(roomId);
if (history.length > 1) {
room = history[history.length - 1]; // Last room is most recent
// Take out any room that isn't the most recent room
for (let i = 0; i < history.length - 1; i++) {
const idx = rooms.findIndex((r) => r.room.roomId === history[i].roomId);
if (idx !== -1) rooms.splice(idx, 1);
}
}
const existingIdx = rooms.findIndex((r) => r.room.roomId === room.roomId);
if (existingIdx !== -1) {
rooms.splice(existingIdx, 1);
}
rooms.splice(0, 0, {room, animated: false});
if (rooms.length > MAX_ROOMS) {
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
}
this.setState({rooms});
if (this._scroller.current) {
this._scroller.current.moveToOrigin();
}
// We don't track room aesthetics (badges, membership, etc) over the wire so we
// don't need to do this elsewhere in the file. Just where we alter the room IDs
// and their order.
const roomIds = rooms.map((r) => r.room.roomId);
if (roomIds.length > 0) {
SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
}
}
_viewRoom(room, index) {
Analytics.trackEvent("Breadcrumbs", "click_node", index);
dis.dispatch({action: "view_room", room_id: room.roomId});
}
_onMouseEnter(room) {
this._onHover(room);
}
_onMouseLeave(room) {
this._onHover(null); // clear hover states
}
_onHover(room) {
const rooms = this.state.rooms.slice();
for (const r of rooms) {
r.hover = room && r.room.roomId === room.roomId;
}
this.setState({rooms});
}
_isDmRoom(room) {
const dmRooms = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
return Boolean(dmRooms);
}
render() {
const Tooltip = sdk.getComponent('elements.Tooltip');
const IndicatorScrollbar = sdk.getComponent('structures.IndicatorScrollbar');
// check for collapsed here and not at parent so we keep rooms in our state
// when collapsing and expanding
if (this.props.collapsed || !this.state.enabled) {
return null;
}
const rooms = this.state.rooms;
const avatars = rooms.map((r, i) => {
const isFirst = i === 0;
const classes = classNames({
"mx_RoomBreadcrumbs_crumb": true,
"mx_RoomBreadcrumbs_preAnimate": isFirst && !r.animated,
"mx_RoomBreadcrumbs_animate": isFirst,
"mx_RoomBreadcrumbs_left": r.left,
});
let tooltip = null;
if (r.hover) {
tooltip = <Tooltip label={r.room.name} />;
}
let badge;
if (r.showCount) {
const badgeClasses = classNames({
'mx_RoomTile_badge': true,
'mx_RoomTile_badgeButton': true,
'mx_RoomTile_badgeRed': r.redBadge,
'mx_RoomTile_badgeUnread': !r.redBadge,
});
badge = <div className={badgeClasses}>{r.formattedCount}</div>;
}
return (
<AccessibleButton
className={classes}
key={r.room.roomId}
onClick={() => this._viewRoom(r.room, i)}
onMouseEnter={() => this._onMouseEnter(r.room)}
onMouseLeave={() => this._onMouseLeave(r.room)}
aria-label={_t("Room %(name)s", {name: r.room.name})}
>
<RoomAvatar room={r.room} width={32} height={32} />
{badge}
{tooltip}
</AccessibleButton>
);
});
return (
<div role="toolbar" aria-label={_t("Recent rooms")}>
<IndicatorScrollbar
ref={this._scroller}
className="mx_RoomBreadcrumbs"
trackHorizontalOverflow={true}
verticalScrollsHorizontally={true}
>
{ avatars }
</IndicatorScrollbar>
</div>
);
}
}

View file

@ -0,0 +1,129 @@
/*
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 { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { _t } from "../../../languageHandler";
import { Room } from "matrix-js-sdk/src/models/room";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import Analytics from "../../../Analytics";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { CSSTransition } from "react-transition-group";
import RoomListStore from "../../../stores/room-list/RoomListStore";
import { DefaultTagID } from "../../../stores/room-list/models";
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import Toolbar from "../../../accessibility/Toolbar";
interface IProps {
}
interface IState {
// Both of these control the animation for the breadcrumbs. For details on the
// actual animation, see the CSS.
//
// doAnimation is to lie to the CSSTransition component (see onBreadcrumbsUpdate
// for info). skipFirst is used to try and reduce jerky animation - also see the
// breadcrumb update function for info on that.
doAnimation: boolean;
skipFirst: boolean;
}
export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState> {
private isMounted = true;
constructor(props: IProps) {
super(props);
this.state = {
doAnimation: true, // technically we want animation on mount, but it won't be perfect
skipFirst: false, // render the thing, as boring as it is
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
public componentWillUnmount() {
this.isMounted = false;
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
private onBreadcrumbsUpdate = () => {
if (!this.isMounted) return;
// We need to trick the CSSTransition component into updating, which means we need to
// tell it to not animate, then to animate a moment later. This causes two updates
// which means two renders. The skipFirst change is so that our don't-animate state
// doesn't show the breadcrumb we're about to reveal as it causes a visual jump/jerk.
// The second update, on the next available tick, causes the "enter" animation to start
// again and this time we want to show the newest breadcrumb because it'll be hidden
// off screen for the animation.
this.setState({doAnimation: false, skipFirst: true});
setTimeout(() => this.setState({doAnimation: true, skipFirst: false}), 0);
};
private viewRoom = (room: Room, index: number) => {
Analytics.trackEvent("Breadcrumbs", "click_node", index);
defaultDispatcher.dispatch({action: "view_room", room_id: room.roomId});
};
public render(): React.ReactElement {
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
const roomTags = RoomListStore.instance.getTagsForRoom(r);
const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];
return (
<RovingAccessibleTooltipButton
className="mx_RoomBreadcrumbs_crumb"
key={r.roomId}
onClick={() => this.viewRoom(r, i)}
aria-label={_t("Room %(name)s", {name: r.name})}
title={r.name}
tooltipClassName="mx_RoomBreadcrumbs_Tooltip"
>
<DecoratedRoomAvatar
room={r}
avatarSize={32}
tag={roomTag}
displayBadge={true}
forceCount={true}
/>
</RovingAccessibleTooltipButton>
);
});
if (tiles.length > 0) {
// NOTE: The CSSTransition timeout MUST match the timeout in our CSS!
return (
<CSSTransition
appear={true} in={this.state.doAnimation} timeout={640}
classNames='mx_RoomBreadcrumbs'
>
<Toolbar className='mx_RoomBreadcrumbs'>
{tiles.slice(this.state.skipFirst ? 1 : 0)}
</Toolbar>
</CSSTransition>
);
} else {
return (
<div className='mx_RoomBreadcrumbs'>
<div className="mx_RoomBreadcrumbs_placeholder">
{_t("No recently visited rooms")}
</div>
</div>
);
}
}
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import * as sdk from '../../../index';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import React from 'react';
import { _t } from '../../../languageHandler';
import PropTypes from 'prop-types';

View file

@ -1,35 +0,0 @@
/*
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.
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 createReactClass from 'create-react-class';
export default createReactClass({
displayName: 'RoomDropTarget',
render: function() {
return (
<div className="mx_RoomDropTarget_container">
<div className="mx_RoomDropTarget">
<div className="mx_RoomDropTarget_label">
{ this.props.label }
</div>
</div>
</div>
);
},
});

View file

@ -26,14 +26,14 @@ import Modal from "../../../Modal";
import RateLimitedFunc from '../../../ratelimitedfunc';
import { linkifyElement } from '../../../HtmlUtils';
import AccessibleButton from '../elements/AccessibleButton';
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';
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import {DefaultTagID} from "../../../stores/room-list/models";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
export default createReactClass({
displayName: 'RoomHeader',
@ -152,28 +152,11 @@ export default createReactClass({
},
render: function() {
const RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
let searchStatus = null;
let cancelButton = null;
let settingsButton = null;
let pinnedEventsButton = null;
const e2eIcon = this.props.e2eStatus ?
<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} />;
}
@ -223,27 +206,27 @@ export default createReactClass({
}
const topicElement =
<div className="mx_RoomHeader_topic" ref={this._topic} title={topic} dir="auto">{ topic }</div>;
const avatarSize = 28;
let roomAvatar;
if (this.props.room) {
roomAvatar = (<RoomAvatar
roomAvatar = <DecoratedRoomAvatar
room={this.props.room}
width={avatarSize}
height={avatarSize}
avatarSize={32}
tag={DefaultTagID.Untagged} // to apply room publicity badging
oobData={this.props.oobData}
viewAvatarOnClick={true} />);
viewAvatarOnClick={true}
/>;
}
if (this.props.onSettingsClick) {
settingsButton =
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_settingsButton"
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_settingsButton"
onClick={this.props.onSettingsClick}
title={_t("Settings")}
>
</AccessibleButton>;
title={_t("Settings")} />;
}
if (this.props.onPinnedClick && SettingsStore.isFeatureEnabled('feature_pinning')) {
if (this.props.onPinnedClick && SettingsStore.getValue('feature_pinning')) {
let pinsIndicator = null;
if (this._hasUnreadPins()) {
pinsIndicator = (<div className="mx_RoomHeader_pinsIndicator mx_RoomHeader_pinsIndicatorUnread" />);
@ -252,55 +235,45 @@ export default createReactClass({
}
pinnedEventsButton =
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_pinnedButton"
onClick={this.props.onPinnedClick} title={_t("Pinned Messages")}>
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_pinnedButton"
onClick={this.props.onPinnedClick}
title={_t("Pinned Messages")}
>
{ pinsIndicator }
</AccessibleButton>;
</AccessibleTooltipButton>;
}
// var leave_button;
// if (this.props.onLeaveClick) {
// leave_button =
// <div className="mx_RoomHeader_button" onClick={this.props.onLeaveClick} title="Leave room">
// <TintableSvg src={require("../../../../res/img/leave.svg")} width="26" height="20"/>
// </div>;
// }
let forgetButton;
if (this.props.onForgetClick) {
forgetButton =
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
onClick={this.props.onForgetClick}
title={_t("Forget room")}
>
</AccessibleButton>;
title={_t("Forget room")} />;
}
let searchButton;
if (this.props.onSearchClick && this.props.inRoom) {
searchButton =
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_searchButton"
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_searchButton"
onClick={this.props.onSearchClick}
title={_t("Search")}
>
</AccessibleButton>;
title={_t("Search")} />;
}
let shareRoomButton;
if (this.props.inRoom) {
shareRoomButton =
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_shareButton"
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_shareButton"
onClick={this.onShareRoomClick}
title={_t('Share room')}
>
</AccessibleButton>;
title={_t('Share room')} />;
}
let manageIntegsButton;
if (this.props.room && this.props.room.roomId && this.props.inRoom) {
manageIntegsButton = <ManageIntegsButton
room={this.props.room}
/>;
manageIntegsButton = <ManageIntegsButton room={this.props.room} />;
}
const rightRow =
@ -313,11 +286,13 @@ export default createReactClass({
{ searchButton }
</div>;
const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;
return (
<div className="mx_RoomHeader light-panel">
<div className="mx_RoomHeader_wrapper" aria-owns="mx_RightPanel">
<div className="mx_RoomHeader_avatar">{ roomAvatar }{ e2eIcon }</div>
{ privateIcon }
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
<div className="mx_RoomHeader_e2eIcon">{ e2eIcon }</div>
{ name }
{ topicElement }
{ cancelButton }

View file

@ -1,850 +0,0 @@
/*
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 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';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import rate_limited_func from "../../../ratelimitedfunc";
import * as Rooms from '../../../Rooms';
import DMRoomMap from '../../../utils/DMRoomMap';
import TagOrderStore from '../../../stores/TagOrderStore';
import RoomListStore, {TAG_DM} 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";
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 * as Unread from "../../../Unread";
import RoomViewStore from "../../../stores/RoomViewStore";
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;
function labelForTagName(tagName) {
if (tagName.startsWith('u.')) return tagName.slice(2);
return tagName;
}
export default createReactClass({
displayName: 'RoomList',
propTypes: {
ConferenceHandler: PropTypes.any,
collapsed: PropTypes.bool.isRequired,
searchFilter: PropTypes.string,
},
getInitialState: function() {
this._hoverClearTimer = null;
this._subListRefs = {
// key => RoomSubList ref
};
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
this.subListSizes = sizesJson ? JSON.parse(sizesJson) : {};
this.collapsedState = collapsedJson ? JSON.parse(collapsedJson) : {};
this._layoutSections = [];
const unfilteredOptions = {
allowWhitespace: false,
handleHeight: 1,
};
this._unfilteredlayout = new Layout((key, size) => {
const subList = this._subListRefs[key];
if (subList) {
subList.setHeight(size);
}
// update overflow indicators
this._checkSubListsOverflow();
// don't store height for collapsed sublists
if (!this.collapsedState[key]) {
this.subListSizes[key] = size;
window.localStorage.setItem("mx_roomlist_sizes",
JSON.stringify(this.subListSizes));
}
}, this.subListSizes, this.collapsedState, unfilteredOptions);
this._filteredLayout = new Layout((key, size) => {
const subList = this._subListRefs[key];
if (subList) {
subList.setHeight(size);
}
}, null, null, {
allowWhitespace: false,
handleHeight: 0,
});
this._layout = this._unfilteredlayout;
return {
isLoadingLeftRooms: false,
totalRoomCount: null,
lists: {},
incomingCallTag: null,
incomingCall: null,
selectedTags: [],
hover: false,
customTags: CustomRoomTagStore.getTags(),
};
},
// TODO: [REACT-WARNING] Replace component with real class, put this in the constructor.
UNSAFE_componentWillMount: function() {
this.mounted = false;
const cli = MatrixClientPeg.get();
cli.on("Room", this.onRoom);
cli.on("deleteRoom", this.onDeleteRoom);
cli.on("Room.receipt", this.onRoomReceipt);
cli.on("RoomMember.name", this.onRoomMemberName);
cli.on("Event.decrypted", this.onEventDecrypted);
cli.on("accountData", this.onAccountData);
cli.on("Group.myMembership", this._onGroupMyMembership);
cli.on("RoomState.events", this.onRoomStateEvents);
const dmRoomMap = DMRoomMap.shared();
// A map between tags which are group IDs and the room IDs of rooms that should be kept
// in the room list when filtering by that tag.
this._visibleRoomsForGroup = {
// $groupId: [$roomId1, $roomId2, ...],
};
// All rooms that should be kept in the room list when filtering.
// By default, show all rooms.
this._visibleRooms = MatrixClientPeg.get().getVisibleRooms();
// Listen to updates to group data. RoomList cares about members and rooms in order
// to filter the room list when group tags are selected.
this._groupStoreToken = GroupStore.registerListener(null, () => {
(TagOrderStore.getOrderedTags() || []).forEach((tag) => {
if (tag[0] !== '+') {
return;
}
// This group's rooms or members may have updated, update rooms for its tag
this.updateVisibleRoomsForTag(dmRoomMap, tag);
this.updateVisibleRooms();
});
});
this._tagStoreToken = TagOrderStore.addListener(() => {
// Filters themselves have changed
this.updateVisibleRooms();
});
this._roomListStoreToken = RoomListStore.addListener(() => {
this._delayedRefreshRoomList();
});
if (SettingsStore.isFeatureEnabled("feature_custom_tags")) {
this._customTagStoreToken = CustomRoomTagStore.addListener(() => {
this.setState({
customTags: CustomRoomTagStore.getTags(),
});
});
}
this.refreshRoomList();
// order of the sublists
//this.listOrder = [];
// loop count to stop a stack overflow if the user keeps waggling the
// mouse for >30s in a row, or if running under mocha
this._delayedRefreshRoomListLoopCount = 0;
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
const cfg = {
getLayout: () => this._layout,
};
this.resizer = new Resizer(this.resizeContainer, Distributor, cfg);
this.resizer.setClassNames({
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse",
});
this._layout.update(
this._layoutSections,
this.resizeContainer && this.resizeContainer.offsetHeight,
);
this._checkSubListsOverflow();
this.resizer.attach();
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("leftPanelResized", this.onResize);
}
this.mounted = true;
},
componentDidUpdate: function(prevProps) {
let forceLayoutUpdate = false;
this._repositionIncomingCallBox(undefined, false);
if (!this.props.searchFilter && prevProps.searchFilter) {
this._layout = this._unfilteredlayout;
forceLayoutUpdate = true;
} else if (this.props.searchFilter && !prevProps.searchFilter) {
this._layout = this._filteredLayout;
forceLayoutUpdate = true;
}
this._layout.update(
this._layoutSections,
this.resizeContainer && this.resizeContainer.clientHeight,
forceLayoutUpdate,
);
this._checkSubListsOverflow();
},
onAction: function(payload) {
switch (payload.action) {
case 'view_tooltip':
this.tooltip = payload.tooltip;
break;
case 'call_state':
var call = CallHandler.getCall(payload.room_id);
if (call && call.call_state === 'ringing') {
this.setState({
incomingCall: call,
incomingCallTag: this.getTagNameForRoomId(payload.room_id),
});
this._repositionIncomingCallBox(undefined, true);
} else {
this.setState({
incomingCall: null,
incomingCallTag: null,
});
}
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;
}
}
},
componentWillUnmount: function() {
this.mounted = false;
dis.unregister(this.dispatcherRef);
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onRoom);
MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName);
MatrixClientPeg.get().removeListener("Event.decrypted", this.onEventDecrypted);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
if (this.props.resizeNotifier) {
this.props.resizeNotifier.removeListener("leftPanelResized", this.onResize);
}
if (this._tagStoreToken) {
this._tagStoreToken.remove();
}
if (this._roomListStoreToken) {
this._roomListStoreToken.remove();
}
if (this._customTagStoreToken) {
this._customTagStoreToken.remove();
}
// NB: GroupStore is not a Flux.Store
if (this._groupStoreToken) {
this._groupStoreToken.unregister();
}
// cancel any pending calls to the rate_limited_funcs
this._delayedRefreshRoomList.cancelPendingCall();
},
onResize: function() {
if (this.mounted && this._layout && this.resizeContainer &&
Array.isArray(this._layoutSections)
) {
this._layout.update(
this._layoutSections,
this.resizeContainer.offsetHeight,
);
}
},
onRoom: function(room) {
this.updateVisibleRooms();
},
onRoomStateEvents: function(ev, state) {
if (ev.getType() === "m.room.create" || ev.getType() === "m.room.tombstone") {
this.updateVisibleRooms();
}
},
onDeleteRoom: function(roomId) {
this.updateVisibleRooms();
},
onArchivedHeaderClick: function(isHidden, scrollToPosition) {
if (!isHidden) {
const self = this;
this.setState({ isLoadingLeftRooms: true });
// we don't care about the response since it comes down via "Room"
// events.
MatrixClientPeg.get().syncLeftRooms().catch(function(err) {
console.error("Failed to sync left rooms: %s", err);
console.error(err);
}).finally(function() {
self.setState({ isLoadingLeftRooms: false });
});
}
},
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 (Receipt.findReadReceiptFromUserId(receiptEvent, MatrixClientPeg.get().credentials.userId)) {
this._delayedRefreshRoomList();
}
},
onRoomMemberName: function(ev, member) {
this._delayedRefreshRoomList();
},
onEventDecrypted: function(ev) {
// An event being decrypted may mean we need to re-order the room list
this._delayedRefreshRoomList();
},
onAccountData: function(ev) {
if (ev.getType() == 'm.direct') {
this._delayedRefreshRoomList();
}
},
_onGroupMyMembership: function(group) {
this.forceUpdate();
},
onMouseMove: async function(ev) {
if (!this._hoverClearTimer) {
this.setState({hover: true});
this._hoverClearTimer = new Timer(HOVER_MOVE_TIMEOUT);
this._hoverClearTimer.start();
let finished = true;
try {
await this._hoverClearTimer.finished();
} catch (err) {
finished = false;
}
this._hoverClearTimer = null;
if (finished) {
this.setState({hover: false});
this._delayedRefreshRoomList();
}
} else {
this._hoverClearTimer.restart();
}
},
onMouseLeave: function(ev) {
if (this._hoverClearTimer) {
this._hoverClearTimer.abort();
this._hoverClearTimer = null;
}
this.setState({hover: false});
// Refresh the room list just in case the user missed something.
this._delayedRefreshRoomList();
},
_delayedRefreshRoomList: rate_limited_func(function() {
this.refreshRoomList();
}, 500),
// Update which rooms and users should appear in RoomList for a given group tag
updateVisibleRoomsForTag: function(dmRoomMap, tag) {
if (!this.mounted) return;
// For now, only handle group tags
if (tag[0] !== '+') return;
this._visibleRoomsForGroup[tag] = [];
GroupStore.getGroupRooms(tag).forEach((room) => this._visibleRoomsForGroup[tag].push(room.roomId));
GroupStore.getGroupMembers(tag).forEach((member) => {
if (member.userId === MatrixClientPeg.get().credentials.userId) return;
dmRoomMap.getDMRoomsForUserId(member.userId).forEach(
(roomId) => this._visibleRoomsForGroup[tag].push(roomId),
);
});
// TODO: Check if room has been tagged to the group by the user
},
// Update which rooms and users should appear according to which tags are selected
updateVisibleRooms: function() {
const selectedTags = TagOrderStore.getSelectedTags();
const visibleGroupRooms = [];
selectedTags.forEach((tag) => {
(this._visibleRoomsForGroup[tag] || []).forEach(
(roomId) => visibleGroupRooms.push(roomId),
);
});
// If there are any tags selected, constrain the rooms listed to the
// visible rooms as determined by visibleGroupRooms. Here, we
// de-duplicate and filter out rooms that the client doesn't know
// about (hence the Set and the null-guard on `room`).
if (selectedTags.length > 0) {
const roomSet = new Set();
visibleGroupRooms.forEach((roomId) => {
const room = MatrixClientPeg.get().getRoom(roomId);
if (room) {
roomSet.add(room);
}
});
this._visibleRooms = Array.from(roomSet);
} else {
// Show all rooms
this._visibleRooms = MatrixClientPeg.get().getVisibleRooms();
}
this._delayedRefreshRoomList();
},
refreshRoomList: function() {
if (this.state.hover) {
// Don't re-sort the list if we're hovering over the list
return;
}
// TODO: ideally we'd calculate this once at start, and then maintain
// any changes to it incrementally, updating the appropriate sublists
// as needed.
// Alternatively we'd do something magical with Immutable.js or similar.
const lists = this.getRoomLists();
let totalRooms = 0;
for (const l of Object.values(lists)) {
totalRooms += l.length;
}
this.setState({
lists,
totalRoomCount: totalRooms,
// Do this here so as to not render every time the selected tags
// themselves change.
selectedTags: TagOrderStore.getSelectedTags(),
}, () => {
// we don't need to restore any size here, do we?
// i guess we could have triggered a new group to appear
// that already an explicit size the last time it appeared ...
this._checkSubListsOverflow();
});
// this._lastRefreshRoomListTs = Date.now();
},
getTagNameForRoomId: function(roomId) {
const lists = RoomListStore.getRoomLists();
for (const tagName of Object.keys(lists)) {
for (const room of lists[tagName]) {
// Should be impossible, but guard anyways.
if (!room) {
continue;
}
const myUserId = MatrixClientPeg.get().getUserId();
if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, myUserId, this.props.ConferenceHandler)) {
continue;
}
if (room.roomId === roomId) return tagName;
}
}
return null;
},
getRoomLists: function() {
const lists = RoomListStore.getRoomLists();
const filteredLists = {};
const isRoomVisible = {
// $roomId: true,
};
this._visibleRooms.forEach((r) => {
isRoomVisible[r.roomId] = true;
});
Object.keys(lists).forEach((tagName) => {
const filteredRooms = lists[tagName].filter((taggedRoom) => {
// Somewhat impossible, but guard against it anyway
if (!taggedRoom) {
return;
}
const myUserId = MatrixClientPeg.get().getUserId();
if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(taggedRoom, myUserId, this.props.ConferenceHandler)) {
return;
}
return Boolean(isRoomVisible[taggedRoom.roomId]);
});
if (filteredRooms.length > 0 || tagName.match(STANDARD_TAGS_REGEX)) {
filteredLists[tagName] = filteredRooms;
}
});
return filteredLists;
},
_getScrollNode: function() {
if (!this.mounted) return null;
const panel = ReactDOM.findDOMNode(this);
if (!panel) return null;
if (panel.classList.contains('gm-prevented')) {
return panel;
} else {
return panel.children[2]; // XXX: Fragile!
}
},
_whenScrolling: function(e) {
this._hideTooltip(e);
this._repositionIncomingCallBox(e, false);
},
_hideTooltip: function(e) {
// Hide tooltip when scrolling, as we'll no longer be over the one we were on
if (this.tooltip && this.tooltip.style.display !== "none") {
this.tooltip.style.display = "none";
}
},
_repositionIncomingCallBox: function(e, firstTime) {
const incomingCallBox = document.getElementById("incomingCallBox");
if (incomingCallBox && incomingCallBox.parentElement) {
const scrollArea = this._getScrollNode();
if (!scrollArea) return;
// Use the offset of the top of the scroll area from the window
// as this is used to calculate the CSS fixed top position for the stickies
const scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset;
// Use the offset of the top of the component from the window
// as this is used to calculate the CSS fixed top position for the stickies
const scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;
let top = (incomingCallBox.parentElement.getBoundingClientRect().top + window.pageYOffset);
// Make sure we don't go too far up, if the headers aren't sticky
top = (top < scrollAreaOffset) ? scrollAreaOffset : top;
// make sure we don't go too far down, if the headers aren't sticky
const bottomMargin = scrollAreaOffset + (scrollAreaHeight - 45);
top = (top > bottomMargin) ? bottomMargin : top;
incomingCallBox.style.top = top + "px";
incomingCallBox.style.left = scrollArea.offsetLeft + scrollArea.offsetWidth + 12 + "px";
}
},
_makeGroupInviteTiles(filter) {
const ret = [];
const lcFilter = filter && filter.toLowerCase();
const GroupInviteTile = sdk.getComponent('groups.GroupInviteTile');
for (const group of MatrixClientPeg.get().getGroups()) {
const {groupId, name, myMembership} = group;
// filter to only groups in invite state and group_id starts with filter or group name includes it
if (myMembership !== 'invite') continue;
if (lcFilter && !groupId.toLowerCase().startsWith(lcFilter) &&
!(name && name.toLowerCase().includes(lcFilter))) continue;
ret.push(<GroupInviteTile key={groupId} group={group} collapsed={this.props.collapsed} />);
}
return ret;
},
_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) => {
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) {
// persist collapsed state
this.collapsedState[key] = collapsed;
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.collapsedState));
// load the persisted size configuration of the expanded sub list
if (collapsed) {
this._layout.collapseSection(key);
} else {
this._layout.expandSection(key, this.subListSizes[key]);
}
// check overflow, as sub lists sizes have changed
// important this happens after calling resize above
this._checkSubListsOverflow();
},
// check overflow for scroll indicator gradient
_checkSubListsOverflow() {
Object.values(this._subListRefs).forEach(l => l.checkOverflow());
},
_subListRef: function(key, ref) {
if (!ref) {
delete this._subListRefs[key];
} else {
this._subListRefs[key] = ref;
}
},
_mapSubListProps: function(subListsProps) {
this._layoutSections = [];
const defaultProps = {
collapsed: this.props.collapsed,
isFiltered: !!this.props.searchFilter,
};
subListsProps.forEach((p) => {
p.list = this._applySearchFilter(p.list, this.props.searchFilter);
});
subListsProps = subListsProps.filter((props => {
const len = props.list.length + (props.extraTiles ? props.extraTiles.length : 0);
return len !== 0 || props.onAddRoom;
}));
return subListsProps.reduce((components, props, i) => {
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;
const chosenKey = key || label;
const onSubListHeaderClick = (collapsed) => {
this._handleCollapsedState(chosenKey, collapsed);
if (onHeaderClick) {
onHeaderClick(collapsed);
}
};
const startAsHidden = props.startAsHidden || this.collapsedState[chosenKey];
this._layoutSections.push({
id: chosenKey,
count: len,
});
const subList = (<RoomSubList
ref={this._subListRef.bind(this, chosenKey)}
startAsHidden={startAsHidden}
forceExpand={!!this.props.searchFilter}
onHeaderClick={onSubListHeaderClick}
key={chosenKey}
label={label}
{...otherProps} />);
if (!isLast) {
return components.concat(
subList,
<ResizeHandle key={chosenKey+"-resizer"} vertical={true} id={chosenKey} />
);
} else {
return components.concat(subList);
}
}, []);
},
_collectResizeContainer: function(el) {
this.resizeContainer = el;
},
render: function() {
const incomingCallIfTaggedAs = (tagName) => {
if (!this.state.incomingCall) return null;
if (this.state.incomingCallTag !== tagName) return null;
return this.state.incomingCall;
};
let subLists = [
{
list: [],
extraTiles: this._makeGroupInviteTiles(this.props.searchFilter),
label: _t('Community Invites'),
isInvite: true,
},
{
list: this.state.lists['im.vector.fake.invite'],
label: _t('Invites'),
incomingCall: incomingCallIfTaggedAs('im.vector.fake.invite'),
isInvite: true,
},
{
list: this.state.lists['m.favourite'],
label: _t('Favourites'),
tagName: "m.favourite",
incomingCall: incomingCallIfTaggedAs('m.favourite'),
},
{
list: this.state.lists[TAG_DM],
label: _t('Direct Messages'),
tagName: TAG_DM,
incomingCall: incomingCallIfTaggedAs(TAG_DM),
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
addRoomLabel: _t("Start chat"),
},
{
list: this.state.lists['im.vector.fake.recent'],
label: _t('Rooms'),
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
onAddRoom: () => {dis.dispatch({action: 'view_create_room'});},
},
];
const tagSubLists = Object.keys(this.state.lists)
.filter((tagName) => {
return (!this.state.customTags || this.state.customTags[tagName]) &&
!tagName.match(STANDARD_TAGS_REGEX);
}).map((tagName) => {
return {
list: this.state.lists[tagName],
key: tagName,
label: labelForTagName(tagName),
tagName: tagName,
incomingCall: incomingCallIfTaggedAs(tagName),
};
});
subLists = subLists.concat(tagSubLists);
subLists = subLists.concat([
{
list: this.state.lists['m.lowpriority'],
label: _t('Low priority'),
tagName: "m.lowpriority",
incomingCall: incomingCallIfTaggedAs('m.lowpriority'),
},
{
list: this.state.lists['im.vector.fake.archived'],
label: _t('Historical'),
incomingCall: incomingCallIfTaggedAs('im.vector.fake.archived'),
startAsHidden: true,
showSpinner: this.state.isLoadingLeftRooms,
onHeaderClick: this.onArchivedHeaderClick,
},
{
list: this.state.lists['m.server_notice'],
label: _t('System Alerts'),
tagName: "m.lowpriority",
incomingCall: incomingCallIfTaggedAs('m.server_notice'),
},
]);
const subListComponents = this._mapSubListProps(subLists);
const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, onKeyDown, ...props} = this.props; // eslint-disable-line
return (
<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,396 @@
/*
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 { Dispatcher } from "flux";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t, _td } from "../../../languageHandler";
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import RoomViewStore from "../../../stores/RoomViewStore";
import { ITagMap } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, isCustomTag, TagID } from "../../../stores/room-list/models";
import dis from "../../../dispatcher/dispatcher";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomSublist from "./RoomSublist";
import { ActionPayload } from "../../../dispatcher/payloads";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import GroupAvatar from "../avatars/GroupAvatar";
import TemporaryTile from "./TemporaryTile";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import SettingsStore from "../../../settings/SettingsStore";
import CustomRoomTagStore from "../../../stores/CustomRoomTagStore";
import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays";
import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
import AccessibleButton from "../elements/AccessibleButton";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void;
onResize: () => void;
resizeNotifier: ResizeNotifier;
collapsed: boolean;
isMinimized: boolean;
}
interface IState {
sublists: ITagMap;
}
const TAG_ORDER: TagID[] = [
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Untagged,
// -- Custom Tags Placeholder --
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Archived,
];
const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
const ALWAYS_VISIBLE_TAGS: TagID[] = [
DefaultTagID.DM,
DefaultTagID.Untagged,
];
interface ITagAesthetics {
sectionLabel: string;
sectionLabelRaw?: string;
addRoomLabel?: string;
onAddRoom?: (dispatcher?: Dispatcher<ActionPayload>) => void;
addRoomContextMenu?: (onFinished: () => void) => React.ReactNode;
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("People"),
isInvite: false,
defaultHidden: false,
addRoomLabel: _td("Start chat"),
onAddRoom: (dispatcher?: Dispatcher<ActionPayload>) => {
(dispatcher || defaultDispatcher).dispatch({action: 'view_create_chat'});
},
},
[DefaultTagID.Untagged]: {
sectionLabel: _td("Rooms"),
isInvite: false,
defaultHidden: false,
addRoomLabel: _td("Add room"),
addRoomContextMenu: (onFinished: () => void) => {
return <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
defaultDispatcher.dispatch({action: "view_create_room"});
}}
/>
<IconizedContextMenuOption
label={_t("Explore public rooms")}
iconClassName="mx_RoomList_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
defaultDispatcher.fire(Action.ViewRoomDirectory);
}}
/>
</IconizedContextMenuOptionList>;
},
},
[DefaultTagID.LowPriority]: {
sectionLabel: _td("Low priority"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.ServerNotice]: {
sectionLabel: _td("System Alerts"),
isInvite: false,
defaultHidden: false,
},
// TODO: Replace with archived view: https://github.com/vector-im/element-web/issues/14038
[DefaultTagID.Archived]: {
sectionLabel: _td("Historical"),
isInvite: false,
defaultHidden: true,
},
};
function customTagAesthetics(tagId: TagID): ITagAesthetics {
if (tagId.startsWith("u.")) {
tagId = tagId.substring(2);
}
return {
sectionLabel: _td("Custom Tag"),
sectionLabelRaw: tagId,
isInvite: false,
defaultHidden: false,
};
}
export default class RoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef;
private customTagStoreRef;
constructor(props: IProps) {
super(props);
this.state = {
sublists: {},
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
public componentDidMount(): void {
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
this.customTagStoreRef = CustomRoomTagStore.addListener(this.updateLists);
this.updateLists(); // trigger the first update
}
public componentWillUnmount() {
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
defaultDispatcher.unregister(this.dispatcherRef);
if (this.customTagStoreRef) this.customTagStoreRef.remove();
}
private onAction = (payload: ActionPayload) => {
if (payload.action === Action.ViewRoomDelta) {
const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
const currentRoomId = RoomViewStore.getRoomId();
const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread);
if (room) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
});
}
}
};
private getRoomDelta = (roomId: string, delta: number, unread = false) => {
const lists = RoomListStore.instance.orderedLists;
let rooms: Room = [];
TAG_ORDER.forEach(t => {
let listRooms = lists[t];
if (unread) {
// filter to only notification rooms (and our current active room so we can index properly)
listRooms = listRooms.filter(r => {
const state = RoomNotificationStateStore.instance.getRoomState(r);
return state.room.roomId === roomId || state.isUnread;
});
}
rooms.push(...listRooms);
});
const currentIndex = rooms.findIndex(r => r.roomId === roomId);
// use slice to account for looping around the start
const [room] = rooms.slice((currentIndex + delta) % rooms.length);
return room;
};
private updateLists = () => {
const newLists = RoomListStore.instance.orderedLists;
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log("new lists", newLists);
}
const previousListIds = Object.keys(this.state.sublists);
const newListIds = Object.keys(newLists).filter(t => {
if (!isCustomTag(t)) return true; // always include non-custom tags
// if the tag is custom though, only include it if it is enabled
return CustomRoomTagStore.getTags()[t];
});
let doUpdate = arrayHasDiff(previousListIds, newListIds);
if (!doUpdate) {
// so we didn't have the visible sublists change, but did the contents of those
// sublists change significantly enough to break the sticky headers? Probably, so
// let's check the length of each.
for (const tagId of newListIds) {
const oldRooms = this.state.sublists[tagId];
const newRooms = newLists[tagId];
if (oldRooms.length !== newRooms.length) {
doUpdate = true;
break;
}
}
}
if (doUpdate) {
// We have to break our reference to the room list store if we want to be able to
// diff the object for changes, so do that.
// @ts-ignore - ITagMap is ts-ignored so this will have to be too
const newSublists = objectWithOnly(newLists, newListIds);
const sublists = objectShallowClone(newSublists, (k, v) => arrayFastClone(v));
this.setState({sublists}, () => {
this.props.onResize();
});
}
};
private onExplore = () => {
dis.fire(Action.ViewRoomDirectory);
};
private renderCommunityInvites(): TemporaryTile[] {
// TODO: Put community invites in a more sensible place (not in the room list)
// See https://github.com/vector-im/element-web/issues/14456
return MatrixClientPeg.get().getGroups().filter(g => {
return g.myMembership === 'invite';
}).map(g => {
const avatar = (
<GroupAvatar
groupId={g.groupId}
groupName={g.name}
groupAvatarUrl={g.avatarUrl}
width={32} height={32} resizeMethod='crop'
/>
);
const openGroup = () => {
defaultDispatcher.dispatch({
action: 'view_group',
group_id: g.groupId,
});
};
return (
<TemporaryTile
isMinimized={this.props.isMinimized}
isSelected={false}
displayName={g.name}
avatar={avatar}
notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
onClick={openGroup}
key={`temporaryGroupTile_${g.groupId}`}
/>
);
});
}
private renderSublists(): React.ReactElement[] {
const components: React.ReactElement[] = [];
const tagOrder = TAG_ORDER.reduce((p, c) => {
if (c === CUSTOM_TAGS_BEFORE_TAG) {
const customTags = Object.keys(this.state.sublists)
.filter(t => isCustomTag(t));
p.push(...customTags);
}
p.push(c);
return p;
}, [] as TagID[]);
for (const orderedTagId of tagOrder) {
const orderedRooms = this.state.sublists[orderedTagId] || [];
const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
const totalTiles = orderedRooms.length + (extraTiles ? extraTiles.length : 0);
if (totalTiles === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
continue; // skip tag - not needed
}
const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
? customTagAesthetics(orderedTagId)
: TAG_AESTHETICS[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
components.push(
<RoomSublist
key={`sublist-${orderedTagId}`}
tagId={orderedTagId}
forRooms={true}
startAsHidden={aesthetics.defaultHidden}
label={aesthetics.sectionLabelRaw ? aesthetics.sectionLabelRaw : _t(aesthetics.sectionLabel)}
onAddRoom={aesthetics.onAddRoom}
addRoomLabel={aesthetics.addRoomLabel ? _t(aesthetics.addRoomLabel) : aesthetics.addRoomLabel}
addRoomContextMenu={aesthetics.addRoomContextMenu}
isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
extraBadTilesThatShouldntExist={extraTiles}
/>
);
}
return components;
}
public render() {
let explorePrompt: JSX.Element;
if (RoomListStore.instance.getFirstNameFilterCondition()) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Can't see what youre looking for?")}</div>
<AccessibleButton kind="link" onClick={this.onExplore}>
{_t("Explore all public rooms")}
</AccessibleButton>
</div>;
}
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")}
>
{sublists}
{explorePrompt}
</div>
)}
</RovingTabIndexProvider>
);
}
}

View file

@ -0,0 +1,41 @@
/*
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, {useState} from "react";
import { _t } from "../../../languageHandler";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
const RoomListNumResults: React.FC = () => {
const [count, setCount] = useState<number>(null);
useEventEmitter(RoomListStore.instance, LISTS_UPDATE_EVENT, () => {
if (RoomListStore.instance.getFirstNameFilterCondition()) {
const numRooms = Object.values(RoomListStore.instance.orderedLists).flat(1).length;
setCount(numRooms);
} else {
setCount(null);
}
});
if (typeof count !== "number") return null;
return <div className="mx_LeftPanel_roomListFilterCount">
{_t("%(count)s results", { count })}
</div>;
};
export default RoomListNumResults;

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
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.
@ -21,9 +21,10 @@ import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import SdkConfig from "../../../SdkConfig";
import IdentityAuthClient from '../../../IdentityAuthClient';
const MessageCase = Object.freeze({
@ -266,9 +267,9 @@ export default 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,11 +283,11 @@ export default createReactClass({
},
render: function() {
const brand = SdkConfig.get().brand;
const Spinner = sdk.getComponent('elements.Spinner');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let showSpinner = false;
let darkStyle = false;
let title;
let subTitle;
let primaryActionHandler;
@ -314,7 +315,6 @@ export default createReactClass({
break;
}
case MessageCase.NotLoggedIn: {
darkStyle = true;
title = _t("Join the conversation with an account");
primaryActionLabel = _t("Sign Up");
primaryActionHandler = this.onRegisterClick;
@ -398,7 +398,8 @@ export default createReactClass({
);
subTitle = _t(
"Link this email with your account in Settings to receive invites " +
"directly in Riot.",
"directly in %(brand)s.",
{ brand },
);
primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick;
@ -413,7 +414,8 @@ export default createReactClass({
},
);
subTitle = _t(
"Use an identity server in Settings to receive invites directly in Riot.",
"Use an identity server in Settings to receive invites directly in %(brand)s.",
{ brand },
);
primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick;
@ -428,7 +430,8 @@ export default createReactClass({
},
);
subTitle = _t(
"Share this email in Settings to receive invites directly in Riot.",
"Share this email in Settings to receive invites directly in %(brand)s.",
{ brand },
);
primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick;
@ -508,7 +511,7 @@ export default createReactClass({
"If you think you're seeing this message in error, please " +
"<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"
{ issueLink: label => <a href="https://github.com/vector-im/element-web/issues/new/choose"
target="_blank" rel="noreferrer noopener">{ label }</a> },
),
];
@ -552,7 +555,6 @@ export default createReactClass({
const classes = classNames("mx_RoomPreviewBar", "dark-panel", `mx_RoomPreviewBar_${messageCase}`, {
"mx_RoomPreviewBar_panel": this.props.canPreview,
"mx_RoomPreviewBar_dialog": !this.props.canPreview,
"mx_RoomPreviewBar_dark": darkStyle,
});
return (

View file

@ -21,7 +21,8 @@ import * as sdk from "../../../index";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import SettingsStore from "../../../settings/SettingsStore";
import {SettingLevel} from "../../../settings/SettingLevel";
export default class RoomRecoveryReminder extends React.PureComponent {
static propTypes = {

View file

@ -0,0 +1,891 @@
/*
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 { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomTile from "./RoomTile";
import { ListLayout } from "../../../stores/room-list/ListLayout";
import {
ChevronFace,
ContextMenu,
ContextMenuTooltipButton,
StyledMenuItemCheckbox,
StyledMenuItemRadio,
} from "../../structures/ContextMenu";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import dis from "../../../dispatcher/dispatcher";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import NotificationBadge from "./NotificationBadge";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Key } from "../../../Keyboard";
import { ActionPayload } from "../../../dispatcher/payloads";
import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import { polyfillTouchEvent } from "../../../@types/polyfill";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays";
import { objectExcluding, objectHasDiff } from "../../../utils/objects";
import TemporaryTile from "./TemporaryTile";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import IconizedContextMenu from "../context_menus/IconizedContextMenu";
const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
export const HEADER_HEIGHT = 32; // As defined by CSS
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
// HACK: We really shouldn't have to do this.
polyfillTouchEvent();
interface IProps {
forRooms: boolean;
startAsHidden: boolean;
label: string;
onAddRoom?: () => void;
addRoomContextMenu?: (onFinished: () => void) => React.ReactNode;
addRoomLabel: string;
isMinimized: boolean;
tagId: TagID;
onResize: () => void;
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
// You should feel bad if you use this.
extraBadTilesThatShouldntExist?: TemporaryTile[];
// TODO: Account for https://github.com/vector-im/element-web/issues/14179
}
// TODO: Use re-resizer's NumberSize when it is exposed as the type
interface ResizeDelta {
width: number;
height: number;
}
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
interface IState {
contextMenuPosition: PartialDOMRect;
addRoomContextMenuPosition: PartialDOMRect;
isResizing: boolean;
isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
height: number;
rooms: Room[];
filteredExtraTiles?: TemporaryTile[];
}
export default class RoomSublist extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>();
private dispatcherRef: string;
private layout: ListLayout;
private heightAtStart: number;
private isBeingFiltered: boolean;
private notificationState: ListNotificationState;
constructor(props: IProps) {
super(props);
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.heightAtStart = 0;
this.isBeingFiltered = !!RoomListStore.instance.getFirstNameFilterCondition();
this.notificationState = RoomNotificationStateStore.instance.getListState(this.props.tagId);
this.state = {
contextMenuPosition: null,
addRoomContextMenuPosition: null,
isResizing: false,
isExpanded: this.isBeingFiltered ? this.isBeingFiltered : !this.layout.isCollapsed,
height: 0, // to be fixed in a moment, we need `rooms` to calculate this.
rooms: arrayFastClone(RoomListStore.instance.orderedLists[this.props.tagId] || []),
};
// Why Object.assign() and not this.state.height? Because TypeScript says no.
this.state = Object.assign(this.state, {height: this.calculateInitialHeight()});
this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
}
private calculateInitialHeight() {
const requestedVisibleTiles = Math.max(Math.floor(this.layout.visibleTiles), this.layout.minVisibleTiles);
const tileCount = Math.min(this.numTiles, requestedVisibleTiles);
return this.layout.tilesToPixelsWithPadding(tileCount, this.padding);
}
private get padding() {
let padding = RESIZE_HANDLE_HEIGHT;
// this is used for calculating the max height of the whole container,
// and takes into account whether there should be room reserved for the show more/less button
// when fully expanded. We can't rely purely on the layout's defaultVisible tile count
// because there are conditions in which we need to know that the 'show more' button
// is present while well under the default tile limit.
const needsShowMore = this.numTiles > this.numVisibleTiles;
// ...but also check this or we'll miss if the section is expanded and we need a
// 'show less'
const needsShowLess = this.numTiles > this.layout.defaultVisibleTiles;
if (needsShowMore || needsShowLess) {
padding += SHOW_N_BUTTON_HEIGHT;
}
return padding;
}
private get extraTiles(): TemporaryTile[] | null {
if (this.state.filteredExtraTiles) {
return this.state.filteredExtraTiles;
}
if (this.props.extraBadTilesThatShouldntExist) {
return this.props.extraBadTilesThatShouldntExist;
}
return null;
}
private get numTiles(): number {
return RoomSublist.calcNumTiles(this.state.rooms, this.extraTiles);
}
private static calcNumTiles(rooms: Room[], extraTiles: any[]) {
return (rooms || []).length + (extraTiles || []).length;
}
private get numVisibleTiles(): number {
const nVisible = Math.ceil(this.layout.visibleTiles);
return Math.min(nVisible, this.numTiles);
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>) {
const prevExtraTiles = prevState.filteredExtraTiles || prevProps.extraBadTilesThatShouldntExist;
// as the rooms can come in one by one we need to reevaluate
// the amount of available rooms to cap the amount of requested visible rooms by the layout
if (RoomSublist.calcNumTiles(prevState.rooms, prevExtraTiles) !== this.numTiles) {
this.setState({height: this.calculateInitialHeight()});
}
}
public shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>): boolean {
if (objectHasDiff(this.props, nextProps)) {
// Something we don't care to optimize has updated, so update.
return true;
}
// Do the same check used on props for state, without the rooms we're going to no-op
const prevStateNoRooms = objectExcluding(this.state, ['rooms']);
const nextStateNoRooms = objectExcluding(nextState, ['rooms']);
if (objectHasDiff(prevStateNoRooms, nextStateNoRooms)) {
return true;
}
// If we're supposed to handle extra tiles, take the performance hit and re-render all the
// time so we don't have to consider them as part of the visible room optimization.
const prevExtraTiles = this.props.extraBadTilesThatShouldntExist || [];
const nextExtraTiles = (nextState.filteredExtraTiles || nextProps.extraBadTilesThatShouldntExist) || [];
if (prevExtraTiles.length > 0 || nextExtraTiles.length > 0) {
return true;
}
// If we're about to update the height of the list, we don't really care about which rooms
// are visible or not for no-op purposes, so ensure that the height calculation runs through.
if (RoomSublist.calcNumTiles(nextState.rooms, nextExtraTiles) !== this.numTiles) {
return true;
}
// Before we go analyzing the rooms, we can see if we're collapsed. If we're collapsed, we don't need
// to render anything. We do this after the height check though to ensure that the height gets appropriately
// calculated for when/if we become uncollapsed.
if (!nextState.isExpanded) {
return false;
}
// Quickly double check we're not about to break something due to the number of rooms changing.
if (this.state.rooms.length !== nextState.rooms.length) {
return true;
}
// Finally, determine if the room update (as presumably that's all that's left) is within
// our visible range. If it is, then do a render. If the update is outside our visible range
// then we can skip the update.
//
// We also optimize for order changing here: if the update did happen in our visible range
// but doesn't result in the list re-sorting itself then there's no reason for us to update
// on our own.
const prevSlicedRooms = this.state.rooms.slice(0, this.numVisibleTiles);
const nextSlicedRooms = nextState.rooms.slice(0, this.numVisibleTiles);
if (arrayHasOrderChange(prevSlicedRooms, nextSlicedRooms)) {
return true;
}
// Finally, nothing happened so no-op the update
return false;
}
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated);
}
private onListsUpdated = () => {
const stateUpdates: IState & any = {}; // &any is to avoid a cast on the initializer
if (this.props.extraBadTilesThatShouldntExist) {
const nameCondition = RoomListStore.instance.getFirstNameFilterCondition();
if (nameCondition) {
stateUpdates.filteredExtraTiles = this.props.extraBadTilesThatShouldntExist
.filter(t => nameCondition.matches(t.props.displayName || ""));
} else if (this.state.filteredExtraTiles) {
stateUpdates.filteredExtraTiles = null;
}
}
const currentRooms = this.state.rooms;
const newRooms = arrayFastClone(RoomListStore.instance.orderedLists[this.props.tagId] || []);
if (arrayHasOrderChange(currentRooms, newRooms)) {
stateUpdates.rooms = newRooms;
}
const isStillBeingFiltered = !!RoomListStore.instance.getFirstNameFilterCondition();
if (isStillBeingFiltered !== this.isBeingFiltered) {
this.isBeingFiltered = isStillBeingFiltered;
if (isStillBeingFiltered) {
stateUpdates.isExpanded = true;
} else {
stateUpdates.isExpanded = !this.layout.isCollapsed;
}
}
if (Object.keys(stateUpdates).length > 0) {
this.setState(stateUpdates);
}
};
private onAction = (payload: ActionPayload) => {
if (payload.action === "view_room" && payload.show_room_tile && this.state.rooms) {
// XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
// where we lose the room we are changing from temporarily and then it comes back in an update right after.
setImmediate(() => {
const roomIndex = this.state.rooms.findIndex((r) => r.roomId === payload.room_id);
if (!this.state.isExpanded && roomIndex > -1) {
this.toggleCollapsed();
}
// extend the visible section to include the room if it is entirely invisible
if (roomIndex >= this.numVisibleTiles) {
this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render
}
});
}
};
private onAddRoom = (e) => {
e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom();
};
private applyHeightChange(newHeight: number) {
const heightInTiles = Math.ceil(this.layout.pixelsToTiles(newHeight - this.padding));
this.layout.visibleTiles = Math.min(this.numTiles, heightInTiles);
}
private onResize = (
e: MouseEvent | TouchEvent,
travelDirection: Direction,
refToElement: HTMLDivElement,
delta: ResizeDelta,
) => {
const newHeight = this.heightAtStart + delta.height;
this.applyHeightChange(newHeight);
this.setState({height: newHeight});
};
private onResizeStart = () => {
this.heightAtStart = this.state.height;
this.setState({isResizing: true});
};
private onResizeStop = (
e: MouseEvent | TouchEvent,
travelDirection: Direction,
refToElement: HTMLDivElement,
delta: ResizeDelta,
) => {
const newHeight = this.heightAtStart + delta.height;
this.applyHeightChange(newHeight);
this.setState({isResizing: false, height: newHeight});
};
private onShowAllClick = () => {
// read number of visible tiles before we mutate it
const numVisibleTiles = this.numVisibleTiles;
const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
this.applyHeightChange(newHeight);
this.setState({height: newHeight}, () => {
// focus the top-most new room
this.focusRoomTile(numVisibleTiles);
});
};
private onShowLessClick = () => {
const newHeight = this.layout.tilesToPixelsWithPadding(this.layout.defaultVisibleTiles, this.padding);
this.applyHeightChange(newHeight);
this.setState({height: newHeight});
};
private focusRoomTile = (index: number) => {
if (!this.sublistRef.current) return;
const elements = this.sublistRef.current.querySelectorAll<HTMLDivElement>(".mx_RoomTile");
const element = elements && elements[index];
if (element) {
element.focus();
}
};
private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({contextMenuPosition: target.getBoundingClientRect()});
};
private onContextMenu = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
left: ev.clientX,
top: ev.clientY,
height: 0,
},
});
};
private onAddRoomContextMenu = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({addRoomContextMenuPosition: target.getBoundingClientRect()});
};
private onCloseMenu = () => {
this.setState({contextMenuPosition: null});
};
private onCloseAddRoomMenu = () => {
this.setState({addRoomContextMenuPosition: null});
};
private onUnreadFirstChanged = async () => {
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
};
private onTagSortChanged = async (sort: SortAlgorithm) => {
await RoomListStore.instance.setTagSorting(this.props.tagId, sort);
};
private onMessagePreviewChanged = () => {
this.layout.showPreviews = !this.layout.showPreviews;
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onBadgeClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
let room;
if (this.props.tagId === DefaultTagID.Invite) {
// switch to first room as that'll be the top of the list for the user
room = this.state.rooms && this.state.rooms[0];
} else {
// find the first room with a count of the same colour as the badge count
room = this.state.rooms.find((r: Room) => {
const notifState = this.notificationState.getForRoom(r);
return notifState.count > 0 && notifState.color === this.notificationState.color;
});
}
if (room) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
});
}
};
private onHeaderClick = () => {
const possibleSticky = this.headerButton.current.parentElement;
const sublist = possibleSticky.parentElement.parentElement;
const list = sublist.parentElement.parentElement;
// the scrollTop is capped at the height of the header in LeftPanel, the top header is always sticky
const isAtTop = list.scrollTop <= HEADER_HEIGHT;
const isAtBottom = list.scrollTop >= list.scrollHeight - list.offsetHeight;
const isStickyTop = possibleSticky.classList.contains('mx_RoomSublist_headerContainer_stickyTop');
const isStickyBottom = possibleSticky.classList.contains('mx_RoomSublist_headerContainer_stickyBottom');
if ((isStickyBottom && !isAtBottom) || (isStickyTop && !isAtTop)) {
// is sticky - jump to list
sublist.scrollIntoView({behavior: 'smooth'});
} else {
// on screen - toggle collapse
const isExpanded = this.state.isExpanded;
this.toggleCollapsed();
// if the bottom list is collapsed then scroll it in so it doesn't expand off screen
if (!isExpanded && isStickyBottom) {
setImmediate(() => {
sublist.scrollIntoView({behavior: 'smooth'});
});
}
}
};
private toggleCollapsed = () => {
this.layout.isCollapsed = this.state.isExpanded;
this.setState({isExpanded: !this.layout.isCollapsed});
setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated
};
private onHeaderKeyDown = (ev: React.KeyboardEvent) => {
switch (ev.key) {
case Key.ARROW_LEFT:
ev.stopPropagation();
if (this.state.isExpanded) {
// On ARROW_LEFT collapse the room sublist if it isn't already
this.toggleCollapsed();
}
break;
case Key.ARROW_RIGHT: {
ev.stopPropagation();
if (!this.state.isExpanded) {
// On ARROW_RIGHT expand the room sublist if it isn't already
this.toggleCollapsed();
} else if (this.sublistRef.current) {
// otherwise focus the first room
const element = this.sublistRef.current.querySelector(".mx_RoomTile") as HTMLDivElement;
if (element) {
element.focus();
}
}
break;
}
}
};
private onKeyDown = (ev: React.KeyboardEvent) => {
switch (ev.key) {
// On ARROW_LEFT go to the sublist header
case Key.ARROW_LEFT:
ev.stopPropagation();
this.headerButton.current.focus();
break;
// Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer
case Key.ARROW_RIGHT:
ev.stopPropagation();
}
};
private renderVisibleTiles(): React.ReactElement[] {
if (!this.state.isExpanded) {
// don't waste time on rendering
return [];
}
const tiles: React.ReactElement[] = [];
if (this.state.rooms) {
const visibleRooms = this.state.rooms.slice(0, this.numVisibleTiles);
for (const room of visibleRooms) {
tiles.push(
<RoomTile
room={room}
key={`room-${room.roomId}`}
showMessagePreview={this.layout.showPreviews}
isMinimized={this.props.isMinimized}
tag={this.props.tagId}
/>
);
}
}
if (this.extraTiles) {
// HACK: We break typing here, but this 'extra tiles' property shouldn't exist.
(tiles as any[]).push(...this.extraTiles);
}
// We only have to do this because of the extra tiles. We do it conditionally
// to avoid spending cycles on slicing. It's generally fine to do this though
// as users are unlikely to have more than a handful of tiles when the extra
// tiles are used.
if (tiles.length > this.numVisibleTiles) {
return tiles.slice(0, this.numVisibleTiles);
}
return tiles;
}
private renderMenu(): React.ReactElement {
let contextMenu = null;
if (this.state.contextMenuPosition) {
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
// Invites don't get some nonsense options, so only add them if we have to.
let otherSections = null;
if (this.props.tagId !== DefaultTagID.Invite) {
otherSections = (
<React.Fragment>
<hr />
<div>
<div className='mx_RoomSublist_contextMenu_title'>{_t("Appearance")}</div>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("Show rooms with unread messages first")}
</StyledMenuItemCheckbox>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged}
checked={this.layout.showPreviews}
>
{_t("Show previews of messages")}
</StyledMenuItemCheckbox>
</div>
</React.Fragment>
);
}
contextMenu = (
<ContextMenu
chevronFace={ChevronFace.None}
left={this.state.contextMenuPosition.left}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu}
>
<div className="mx_RoomSublist_contextMenu">
<div>
<div className='mx_RoomSublist_contextMenu_title'>{_t("Sort by")}</div>
<StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
checked={!isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`}
>
{_t("Activity")}
</StyledMenuItemRadio>
<StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
checked={isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`}
>
{_t("A-Z")}
</StyledMenuItemRadio>
</div>
{otherSections}
</div>
</ContextMenu>
);
} else if (this.state.addRoomContextMenuPosition) {
contextMenu = (
<IconizedContextMenu
chevronFace={ChevronFace.None}
left={this.state.addRoomContextMenuPosition.left - 7} // center align with the handle
top={this.state.addRoomContextMenuPosition.top + this.state.addRoomContextMenuPosition.height}
onFinished={this.onCloseAddRoomMenu}
compact
>
{this.props.addRoomContextMenu(this.onCloseAddRoomMenu)}
</IconizedContextMenu>
);
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_RoomSublist_menuButton"
onClick={this.onOpenMenuClick}
title={_t("List options")}
isExpanded={!!this.state.contextMenuPosition}
/>
{contextMenu}
</React.Fragment>
);
}
private renderHeader(): React.ReactElement {
return (
<RovingTabIndexWrapper inputRef={this.headerButton}>
{({onFocus, isActive, ref}) => {
const tabIndex = isActive ? 0 : -1;
let ariaLabel = _t("Jump to first unread room.");
if (this.props.tagId === DefaultTagID.Invite) {
ariaLabel = _t("Jump to first invite.");
}
const badge = (
<NotificationBadge
forceCount={true}
notification={this.notificationState}
onClick={this.onBadgeClick}
tabIndex={tabIndex}
aria-label={ariaLabel}
/>
);
let addRoomButton = null;
if (!!this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={this.onAddRoom}
className="mx_RoomSublist_auxButton"
tooltipClassName="mx_RoomSublist_addRoomTooltip"
aria-label={this.props.addRoomLabel || _t("Add room")}
title={this.props.addRoomLabel}
/>
);
} else if (this.props.addRoomContextMenu) {
addRoomButton = (
<ContextMenuTooltipButton
tabIndex={tabIndex}
onClick={this.onAddRoomContextMenu}
className="mx_RoomSublist_auxButton"
tooltipClassName="mx_RoomSublist_addRoomTooltip"
aria-label={this.props.addRoomLabel || _t("Add room")}
title={this.props.addRoomLabel}
isExpanded={!!this.state.addRoomContextMenuPosition}
/>
);
}
const collapseClasses = classNames({
'mx_RoomSublist_collapseBtn': true,
'mx_RoomSublist_collapseBtn_collapsed': !this.state.isExpanded,
});
const classes = classNames({
'mx_RoomSublist_headerContainer': true,
'mx_RoomSublist_headerContainer_withAux': !!addRoomButton,
});
const badgeContainer = (
<div className="mx_RoomSublist_badgeContainer">
{badge}
</div>
);
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
if (this.props.isMinimized) {
Button = AccessibleTooltipButton;
}
// Note: the addRoomButton conditionally gets moved around
// the DOM depending on whether or not the list is minimized.
// If we're minimized, we want it below the header so it
// doesn't become sticky.
// The same applies to the notification badge.
return (
<div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus} aria-label={this.props.label}>
<div className="mx_RoomSublist_stickable">
<Button
onFocus={onFocus}
inputRef={ref}
tabIndex={tabIndex}
className="mx_RoomSublist_headerText"
role="treeitem"
aria-expanded={this.state.isExpanded}
aria-level={1}
onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu}
title={this.props.isMinimized ? this.props.label : undefined}
>
<span className={collapseClasses} />
<span>{this.props.label}</span>
</Button>
{this.renderMenu()}
{this.props.isMinimized ? null : badgeContainer}
{this.props.isMinimized ? null : addRoomButton}
</div>
{this.props.isMinimized ? badgeContainer : null}
{this.props.isMinimized ? addRoomButton : null}
</div>
);
}}
</RovingTabIndexWrapper>
);
}
private onScrollPrevent(e: React.UIEvent<HTMLDivElement>) {
// the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable
// this fixes https://github.com/vector-im/element-web/issues/14413
(e.target as HTMLDivElement).scrollTop = 0;
}
public render(): React.ReactElement {
const visibleTiles = this.renderVisibleTiles();
const classes = classNames({
'mx_RoomSublist': true,
'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition,
'mx_RoomSublist_minimized': this.props.isMinimized,
});
let content = null;
if (visibleTiles.length > 0) {
const layout = this.layout; // to shorten calls
const minTiles = Math.min(layout.minVisibleTiles, this.numTiles);
const showMoreAtMinHeight = minTiles < this.numTiles;
const minHeightPadding = RESIZE_HANDLE_HEIGHT + (showMoreAtMinHeight ? SHOW_N_BUTTON_HEIGHT : 0);
const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding);
let maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
const showMoreBtnClasses = classNames({
'mx_RoomSublist_showNButton': true,
});
// If we're hiding rooms, show a 'show more' button to the user. This button
// floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'.
let showNButton = null;
if (maxTilesPx > this.state.height) {
// the height of all the tiles is greater than the section height: we need a 'show more' button
const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT;
const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight);
const numMissing = this.numTiles - amountFullyShown;
const label = _t("Show %(count)s more", {count: numMissing});
let showMoreText = (
<span className='mx_RoomSublist_showNButtonText'>
{label}
</span>
);
if (this.props.isMinimized) showMoreText = null;
showNButton = (
<RovingAccessibleButton
role="treeitem"
onClick={this.onShowAllClick}
className={showMoreBtnClasses}
aria-label={label}
>
<span className='mx_RoomSublist_showMoreButtonChevron mx_RoomSublist_showNButtonChevron'>
{/* set by CSS masking */}
</span>
{showMoreText}
</RovingAccessibleButton>
);
} else if (this.numTiles > this.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less
const label = _t("Show less");
let showLessText = (
<span className='mx_RoomSublist_showNButtonText'>
{label}
</span>
);
if (this.props.isMinimized) showLessText = null;
showNButton = (
<RovingAccessibleButton
role="treeitem"
onClick={this.onShowLessClick}
className={showMoreBtnClasses}
aria-label={label}
>
<span className='mx_RoomSublist_showLessButtonChevron mx_RoomSublist_showNButtonChevron'>
{/* set by CSS masking */}
</span>
{showLessText}
</RovingAccessibleButton>
);
}
// Figure out if we need a handle
const handles: Enable = {
bottom: true, // the only one we need, but the others must be explicitly false
bottomLeft: false,
bottomRight: false,
left: false,
right: false,
top: false,
topLeft: false,
topRight: false,
};
if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
// we're at a minimum, don't have a bottom handle
handles.bottom = false;
}
// We have to account for padding so we can accommodate a 'show more' button and
// the resize handle, which are pinned to the bottom of the container. This is the
// easiest way to have a resize handle below the button as otherwise we're writing
// our own resize handling and that doesn't sound fun.
//
// The layout class has some helpers for dealing with padding, as we don't want to
// apply it in all cases. If we apply it in all cases, the resizing feels like it
// goes backwards and can become wildly incorrect (visibleTiles says 18 when there's
// only mathematically 7 possible).
const handleWrapperClasses = classNames({
'mx_RoomSublist_resizerHandles': true,
'mx_RoomSublist_resizerHandles_showNButton': !!showNButton,
});
content = (
<React.Fragment>
<Resizable
size={{height: this.state.height} as any}
minHeight={minTilesPx}
maxHeight={maxTilesPx}
onResizeStart={this.onResizeStart}
onResizeStop={this.onResizeStop}
onResize={this.onResize}
handleWrapperClass={handleWrapperClasses}
handleClasses={{bottom: "mx_RoomSublist_resizerHandle"}}
className="mx_RoomSublist_resizeBox"
enable={handles}
>
<div className="mx_RoomSublist_tiles" onScroll={this.onScrollPrevent}>
{visibleTiles}
</div>
{showNButton}
</Resizable>
</React.Fragment>
);
}
return (
<div
ref={this.sublistRef}
className={classes}
role="group"
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
>
{this.renderHeader()}
{content}
</div>
);
}
}

View file

@ -1,571 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
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, {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 DMRoomMap from '../../../utils/DMRoomMap';
import * as sdk from '../../../index';
import {ContextMenu, ContextMenuButton, toRightOf} from '../../structures/ContextMenu';
import * as RoomNotifs from '../../../RoomNotifs';
import * as FormattingUtils from '../../../utils/FormattingUtils';
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';
export default createReactClass({
displayName: 'RoomTile',
propTypes: {
onClick: PropTypes.func,
room: PropTypes.object.isRequired,
collapsed: PropTypes.bool.isRequired,
unread: PropTypes.bool.isRequired,
highlight: PropTypes.bool.isRequired,
// If true, apply mx_RoomTile_transparent class
transparent: PropTypes.bool,
isInvite: PropTypes.bool.isRequired,
incomingCall: PropTypes.object,
},
getDefaultProps: function() {
return {
isDragging: false,
};
},
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
roomName: this.props.room.name,
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
notificationCount: this.props.room.getUnreadNotificationCount(),
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
statusMessage: this._getStatusMessage(),
e2eStatus: null,
});
},
_shouldShowStatusMessage() {
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return false;
}
const isInvite = this.props.room.getMyMembership() === "invite";
const isJoined = this.props.room.getMyMembership() === "join";
const looksLikeDm = this.props.room.getInvitedAndJoinedMemberCount() === 2;
return !isInvite && isJoined && looksLikeDm;
},
_getStatusMessageUser() {
if (!MatrixClientPeg.get()) return null; // We've probably been logged out
const selfId = MatrixClientPeg.get().getUserId();
const otherMember = this.props.room.currentState.getMembersExcept([selfId])[0];
if (!otherMember) {
return null;
}
return otherMember.user;
},
_getStatusMessage() {
const statusUser = this._getStatusMessageUser();
if (!statusUser) {
return "";
}
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({
roomName: this.props.room.name,
});
},
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({
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
});
}
},
onAction: function(payload) {
switch (payload.action) {
// XXX: slight hack in order to zero the notification count when a room
// is read. Ideally this state would be given to this via props (as we
// do with `unread`). This is still better than forceUpdating the entire
// RoomList when a room is read.
case 'on_room_read':
if (payload.roomId !== this.props.room.roomId) break;
this.setState({
notificationCount: this.props.room.getUnreadNotificationCount(),
});
break;
// RoomTiles are one of the few components that may show custom status and
// also remain on screen while in Settings toggling the feature. This ensures
// you can clearly see the status hide and show when toggling the feature.
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);
if (this._shouldShowStatusMessage()) {
const statusUser = this._getStatusMessageUser();
if (statusUser) {
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() {
const cli = MatrixClientPeg.get();
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);
if (this._shouldShowStatusMessage()) {
const statusUser = this._getStatusMessageUser();
if (statusUser) {
statusUser.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
}
}
},
// 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.
this.setState({
notificationCount: this.props.room.getUnreadNotificationCount(),
});
},
// Do a simple shallow comparison of props and state to avoid unnecessary
// renders. The assumption made here is that only state and props are used
// in rendering this component and children.
//
// RoomList is frequently made to forceUpdate, so this decreases number of
// RoomTile renderings.
shouldComponentUpdate: function(newProps, newState) {
if (Object.keys(newProps).some((k) => newProps[k] !== this.props[k])) {
return true;
}
if (Object.keys(newState).some((k) => newState[k] !== this.state[k])) {
return true;
}
return false;
},
_onStatusMessageCommitted() {
// The status message `User` object has observed a message change.
this.setState({
statusMessage: this._getStatusMessage(),
});
},
onClick: function(ev) {
if (this.props.onClick) {
this.props.onClick(this.props.room.roomId, ev);
}
},
onMouseEnter: function() {
this.setState( { hover: true });
this.badgeOnMouseEnter();
},
onMouseLeave: function() {
this.setState( { hover: false });
this.badgeOnMouseLeave();
},
badgeOnMouseEnter: function() {
// Only allow non-guests to access the context menu
// and only change it if it needs to change
if (!MatrixClientPeg.get().isGuest() && !this.state.badgeHover) {
this.setState( { badgeHover: true } );
}
},
badgeOnMouseLeave: function() {
this.setState( { badgeHover: false } );
},
_showContextMenu: function(boundingClientRect) {
// Only allow non-guests to access the context menu
if (MatrixClientPeg.get().isGuest()) return;
const state = {
contextMenuPosition: boundingClientRect,
};
// If the badge is clicked, then no longer show tooltip
if (this.props.collapsed) {
state.hover = false;
}
this.setState(state);
},
onContextMenuButtonClick: function(e) {
// Prevent the RoomTile onClick event firing as well
e.stopPropagation();
e.preventDefault();
this._showContextMenu(e.target.getBoundingClientRect());
},
onContextMenu: function(e) {
// Prevent the native context menu
e.preventDefault();
this._showContextMenu({
right: e.clientX,
top: e.clientY,
height: 0,
});
},
closeMenu: function() {
this.setState({
contextMenuPosition: null,
});
this.props.refreshSubList();
},
render: function() {
const isInvite = this.props.room.getMyMembership() === "invite";
const notificationCount = this.props.notificationCount;
// var highlightCount = this.props.room.getUnreadNotificationCount("highlight");
const notifBadges = notificationCount > 0 && RoomNotifs.shouldShowNotifBadge(this.state.notifState);
const mentionBadges = this.props.highlight && RoomNotifs.shouldShowMentionBadge(this.state.notifState);
const badges = notifBadges || mentionBadges;
let subtext = null;
if (this._shouldShowStatusMessage()) {
subtext = this.state.statusMessage;
}
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,
'mx_RoomTile_unread': this.props.unread,
'mx_RoomTile_unreadNotify': notifBadges,
'mx_RoomTile_highlight': mentionBadges,
'mx_RoomTile_invited': isInvite,
'mx_RoomTile_menuDisplayed': isMenuDisplayed,
'mx_RoomTile_noBadges': !badges,
'mx_RoomTile_transparent': this.props.transparent,
'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
});
const avatarClasses = classNames({
'mx_RoomTile_avatar': true,
});
const badgeClasses = classNames({
'mx_RoomTile_badge': true,
'mx_RoomTile_badgeButton': this.state.badgeHover || isMenuDisplayed,
});
let name = this.state.roomName;
if (name == undefined || name == null) 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);
const badgeContent = notificationCount ? limitedCount : '!';
badge = <div className={badgeClasses}>{ badgeContent }</div>;
}
let label;
let subtextLabel;
let tooltip;
if (!this.props.collapsed) {
const nameClasses = classNames({
'mx_RoomTile_name': true,
'mx_RoomTile_invite': this.props.isInvite,
'mx_RoomTile_badgeShown': badges || this.state.badgeHover || isMenuDisplayed,
});
subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null;
// 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" />;
}
//var incomingCallBox;
//if (this.props.incomingCall) {
// var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
// incomingCallBox = <IncomingCallBox incomingCall={ this.props.incomingCall }/>;
//}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let contextMenuButton;
if (!MatrixClientPeg.get().isGuest()) {
contextMenuButton = (
<ContextMenuButton
className="mx_RoomTile_menuButton"
label={_t("Options")}
isExpanded={isMenuDisplayed}
onClick={this.onContextMenuButtonClick} />
);
}
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
let ariaLabel = name;
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).
if (notifBadges && mentionBadges && !isInvite) {
ariaLabel += " " + _t("%(count)s unread messages including mentions.", {
count: notificationCount,
});
} else if (notifBadges) {
ariaLabel += " " + _t("%(count)s unread messages.", { count: notificationCount });
} else if (mentionBadges && !isInvite) {
ariaLabel += " " + _t("Unread mentions.");
} else if (this.props.unread) {
ariaLabel += " " + _t("Unread messages.");
}
let contextMenu;
if (isMenuDisplayed) {
const RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu');
contextMenu = (
<ContextMenu {...toRightOf(this.state.contextMenuPosition)} onFinished={this.closeMenu}>
<RoomTileContextMenu room={this.props.room} onFinished={this.closeMenu} />
</ContextMenu>
);
}
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>
<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,572 @@
/*
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, { ButtonEvent } from "../../views/elements/AccessibleButton";
import dis from '../../../dispatcher/dispatcher';
import defaultDispatcher from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuTooltipButton, MenuItemRadio } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore, ROOM_PREVIEW_CHANGED } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE, } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import NotificationBadge from "./NotificationBadge";
import { Volume } from "../../../RoomNotifsTypes";
import RoomListStore from "../../../stores/room-list/RoomListStore";
import RoomListActions from "../../../actions/RoomListActions";
import { ActionPayload } from "../../../dispatcher/payloads";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { CachedRoomKey, RoomEchoChamber } from "../../../stores/local-echo/RoomEchoChamber";
import { PROPERTY_UPDATED } from "../../../stores/local-echo/GenericEchoChamber";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
IconizedContextMenuOption,
IconizedContextMenuOptionList, IconizedContextMenuRadio
} from "../context_menus/IconizedContextMenu";
interface IProps {
room: Room;
showMessagePreview: boolean;
isMinimized: boolean;
tag: TagID;
}
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
interface IState {
selected: boolean;
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
messagePreview?: string;
}
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
const contextMenuBelow = (elementRect: PartialDOMRect) => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.pageXOffset - 9;
const top = elementRect.bottom + window.pageYOffset + 17;
const chevronFace = ChevronFace.None;
return {left, top, chevronFace};
};
export default class RoomTile extends React.PureComponent<IProps, IState> {
private dispatcherRef: string;
private roomTileRef = createRef<HTMLDivElement>();
private notificationState: NotificationState;
private roomProps: RoomEchoChamber;
constructor(props: IProps) {
super(props);
this.state = {
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
// generatePreview() will return nothing if the user has previews disabled
messagePreview: this.generatePreview(),
};
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
MessagePreviewStore.instance.on(ROOM_PREVIEW_CHANGED, this.onRoomPreviewChanged);
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps = EchoChamber.forRoom(this.props.room);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
}
private onNotificationUpdate = () => {
this.forceUpdate(); // notification state changed - update
};
private onRoomPropertyUpdate = (property: CachedRoomKey) => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile
};
private get showContextMenu(): boolean {
return this.props.tag !== DefaultTagID.Invite;
}
private get showMessagePreview(): boolean {
return !this.props.isMinimized && this.props.showMessagePreview;
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>) {
if (prevProps.showMessagePreview !== this.props.showMessagePreview && this.showMessagePreview) {
this.setState({messagePreview: this.generatePreview()});
}
}
public componentDidMount() {
// 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();
}
}
public componentWillUnmount() {
if (this.props.room) {
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
}
defaultDispatcher.unregister(this.dispatcherRef);
MessagePreviewStore.instance.off(ROOM_PREVIEW_CHANGED, this.onRoomPreviewChanged);
this.notificationState.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
}
private onAction = (payload: ActionPayload) => {
if (payload.action === "view_room" && payload.room_id === this.props.room.roomId && payload.show_room_tile) {
setImmediate(() => {
this.scrollIntoView();
});
}
};
private onRoomPreviewChanged = (room: Room) => {
if (this.props.room && room.roomId === this.props.room.roomId) {
// generatePreview() will return nothing if the user has previews disabled
this.setState({messagePreview: this.generatePreview()});
}
};
private generatePreview(): string | null {
if (!this.showMessagePreview) {
return null;
}
return MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag);
}
private scrollIntoView = () => {
if (!this.roomTileRef.current) return;
this.roomTileRef.current.scrollIntoView({
block: "nearest",
behavior: "auto",
});
};
private onTileClick = (ev: React.KeyboardEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: 'view_room',
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)),
});
};
private onActiveRoomUpdate = (isActive: boolean) => {
this.setState({selected: isActive});
};
private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({notificationsMenuPosition: target.getBoundingClientRect()});
};
private onCloseNotificationsMenu = () => {
this.setState({notificationsMenuPosition: null});
};
private onGeneralMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({generalMenuPosition: target.getBoundingClientRect()});
};
private onContextMenu = (ev: React.MouseEvent) => {
// If we don't have a context menu to show, ignore the action.
if (!this.showContextMenu) return;
ev.preventDefault();
ev.stopPropagation();
this.setState({
generalMenuPosition: {
left: ev.clientX,
bottom: ev.clientY,
},
});
};
private onCloseGeneralMenu = () => {
this.setState({generalMenuPosition: null});
};
private onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
ev.preventDefault();
ev.stopPropagation();
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
const isApplied = RoomListStore.instance.getTagsForRoom(this.props.room).includes(tagId);
const removeTag = isApplied ? tagId : inverseTag;
const addTag = isApplied ? null : tagId;
dis.dispatch(RoomListActions.tagRoom(
MatrixClientPeg.get(),
this.props.room,
removeTag,
addTag,
undefined,
0
));
} else {
console.warn(`Unexpected tag ${tagId} applied to ${this.props.room.room_id}`);
}
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
this.setState({generalMenuPosition: null}); // hide the menu
}
};
private onLeaveRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: 'leave_room',
room_id: this.props.room.roomId,
});
this.setState({generalMenuPosition: null}); // hide the menu
};
private onForgetRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: 'forget_room',
room_id: this.props.room.roomId,
});
this.setState({generalMenuPosition: null}); // hide the menu
};
private onOpenRoomSettings = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: 'open_room_settings',
room_id: this.props.room.roomId,
});
this.setState({generalMenuPosition: null}); // hide the menu
};
private async saveNotifState(ev: ButtonEvent, newState: Volume) {
ev.preventDefault();
ev.stopPropagation();
if (MatrixClientPeg.get().isGuest()) return;
this.roomProps.notificationVolume = newState;
const key = (ev as React.KeyboardEvent).key;
if (key === Key.ENTER) {
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
this.setState({notificationsMenuPosition: null}); // hide the menu
}
}
private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES);
private onClickAlertMe = ev => this.saveNotifState(ev, ALL_MESSAGES_LOUD);
private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY);
private onClickMute = ev => this.saveNotifState(ev, MUTE);
private renderNotificationsMenu(isActive: boolean): React.ReactElement {
if (MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Archived ||
!this.showContextMenu || this.props.isMinimized
) {
// the menu makes no sense in these cases so do not show one
return null;
}
const state = this.roomProps.notificationVolume;
let contextMenu = null;
if (this.state.notificationsMenuPosition) {
contextMenu = <IconizedContextMenu
{...contextMenuBelow(this.state.notificationsMenuPosition)}
onFinished={this.onCloseNotificationsMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList first>
<IconizedContextMenuRadio
label={_t("Use default")}
active={state === ALL_MESSAGES}
iconClassName="mx_RoomTile_iconBell"
onClick={this.onClickAllNotifs}
/>
<IconizedContextMenuRadio
label={_t("All messages")}
active={state === ALL_MESSAGES_LOUD}
iconClassName="mx_RoomTile_iconBellDot"
onClick={this.onClickAlertMe}
/>
<IconizedContextMenuRadio
label={_t("Mentions & Keywords")}
active={state === MENTIONS_ONLY}
iconClassName="mx_RoomTile_iconBellMentions"
onClick={this.onClickMentions}
/>
<IconizedContextMenuRadio
label={_t("None")}
active={state === MUTE}
iconClassName="mx_RoomTile_iconBellCrossed"
onClick={this.onClickMute}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
const classes = classNames("mx_RoomTile_notificationsButton", {
// Show bell icon for the default case too.
mx_RoomTile_iconBell: state === ALL_MESSAGES,
mx_RoomTile_iconBellDot: state === ALL_MESSAGES_LOUD,
mx_RoomTile_iconBellMentions: state === MENTIONS_ONLY,
mx_RoomTile_iconBellCrossed: state === MUTE,
// Only show the icon by default if the room is overridden to muted.
// TODO: [FTUE Notifications] Probably need to detect global mute state
mx_RoomTile_notificationsButton_show: state === MUTE,
});
return (
<React.Fragment>
<ContextMenuTooltipButton
className={classes}
onClick={this.onNotificationsMenuOpenClick}
title={_t("Notification options")}
isExpanded={!!this.state.notificationsMenuPosition}
tabIndex={isActive ? 0 : -1}
/>
{contextMenu}
</React.Fragment>
);
}
private renderGeneralMenu(): React.ReactElement {
if (!this.showContextMenu) return null; // no menu to show
let contextMenu = null;
if (this.state.generalMenuPosition && this.props.tag === DefaultTagID.Archived) {
contextMenu = <IconizedContextMenu
{...contextMenuBelow(this.state.generalMenuPosition)}
onFinished={this.onCloseGeneralMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList red>
<IconizedContextMenuOption
iconClassName="mx_RoomTile_iconSignOut"
label={_t("Forget Room")}
onClick={this.onForgetRoomClick}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
} else if (this.state.generalMenuPosition) {
const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room);
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
const favouriteLabel = isFavorite ? _t("Favourited") : _t("Favourite");
const isLowPriority = roomTags.includes(DefaultTagID.LowPriority);
const lowPriorityLabel = _t("Low Priority");
contextMenu = <IconizedContextMenu
{...contextMenuBelow(this.state.generalMenuPosition)}
onFinished={this.onCloseGeneralMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList>
<IconizedContextMenuCheckbox
onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}
active={isFavorite}
label={favouriteLabel}
iconClassName="mx_RoomTile_iconStar"
/>
<IconizedContextMenuCheckbox
onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}
active={isLowPriority}
label={lowPriorityLabel}
iconClassName="mx_RoomTile_iconArrowDown"
/>
<IconizedContextMenuOption
onClick={this.onOpenRoomSettings}
label={_t("Settings")}
iconClassName="mx_RoomTile_iconSettings"
/>
</IconizedContextMenuOptionList>
<IconizedContextMenuOptionList red>
<IconizedContextMenuOption
onClick={this.onLeaveRoomClick}
label={_t("Leave Room")}
iconClassName="mx_RoomTile_iconSignOut"
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_RoomTile_menuButton"
onClick={this.onGeneralMenuOpenClick}
title={_t("Room options")}
isExpanded={!!this.state.generalMenuPosition}
/>
{contextMenu}
</React.Fragment>
);
}
public render(): React.ReactElement {
const classes = classNames({
'mx_RoomTile': true,
'mx_RoomTile_selected': this.state.selected,
'mx_RoomTile_hasMenuOpen': !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
'mx_RoomTile_minimized': this.props.isMinimized,
});
const roomAvatar = <DecoratedRoomAvatar
room={this.props.room}
avatarSize={32}
tag={this.props.tag}
displayBadge={this.props.isMinimized}
/>;
let badge: React.ReactNode;
if (!this.props.isMinimized) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
}
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
let messagePreview = null;
if (this.showMessagePreview && this.state.messagePreview) {
messagePreview = (
<div className="mx_RoomTile_messagePreview" id={messagePreviewId(this.props.room.roomId)}>
{this.state.messagePreview}
</div>
);
}
const nameClasses = classNames({
"mx_RoomTile_name": true,
"mx_RoomTile_nameWithPreview": !!messagePreview,
"mx_RoomTile_nameHasUnreadEvents": this.notificationState.isUnread,
});
let nameContainer = (
<div className="mx_RoomTile_nameContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
</div>
{messagePreview}
</div>
);
if (this.props.isMinimized) nameContainer = null;
let ariaLabel = name;
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
if (this.props.tag === DefaultTagID.Invite) {
// append nothing
} else if (this.notificationState.hasMentions) {
ariaLabel += " " + _t("%(count)s unread messages including mentions.", {
count: this.notificationState.count,
});
} else if (this.notificationState.hasUnreadCount) {
ariaLabel += " " + _t("%(count)s unread messages.", {
count: this.notificationState.count,
});
} else if (this.notificationState.isUnread) {
ariaLabel += " " + _t("Unread messages.");
}
let ariaDescribedBy: string;
if (this.showMessagePreview) {
ariaDescribedBy = messagePreviewId(this.props.room.roomId);
}
const props: Partial<React.ComponentProps<typeof AccessibleTooltipButton>> = {};
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
if (this.props.isMinimized) {
Button = AccessibleTooltipButton;
props.title = name;
// force the tooltip to hide whilst we are showing the context menu
props.forceHide = !!this.state.generalMenuPosition;
}
return (
<React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({onFocus, isActive, ref}) =>
<Button
{...props}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes}
onClick={this.onTileClick}
onContextMenu={this.onContextMenu}
role="treeitem"
aria-label={ariaLabel}
aria-selected={this.state.selected}
aria-describedby={ariaDescribedBy}
>
{roomAvatar}
{nameContainer}
{badge}
{this.renderGeneralMenu()}
{this.renderNotificationsMenu(isActive)}
</Button>
}
</RovingTabIndexWrapper>
</React.Fragment>
);
}
}

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,
@ -29,7 +29,6 @@ import {
} from '../../../editor/serialize';
import {CommandPartCreator} from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyPreview from "./ReplyPreview";
import RoomViewStore from '../../../stores/RoomViewStore';
import ReplyThread from "../elements/ReplyThread";
import {parseEvent} from '../../../editor/deserialize';
@ -44,6 +43,7 @@ import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RateLimitedFunc from '../../../ratelimitedfunc';
import {Action} from "../../../dispatcher/actions";
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -312,6 +312,7 @@ export default class SendMessageComposer extends React.Component {
event: null,
});
}
dis.dispatch({action: "message_sent"});
}
this.sendHistoryManager.save(this.model);
@ -322,13 +323,8 @@ export default class SendMessageComposer extends React.Component {
this._clearStoredEditorState();
}
componentDidMount() {
this._editorRef.getEditableRootNode().addEventListener("paste", this._onPaste, true);
}
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
this._editorRef.getEditableRootNode().removeEventListener("paste", this._onPaste, true);
}
// TODO: [REACT-WARNING] Move this to constructor
@ -368,7 +364,7 @@ export default class SendMessageComposer extends React.Component {
onAction = (payload) => {
switch (payload.action) {
case 'reply_to_event':
case 'focus_composer':
case Action.FocusComposer:
this._editorRef && this._editorRef.focus();
break;
case 'insert_mention':
@ -377,6 +373,9 @@ export default class SendMessageComposer extends React.Component {
case 'quote':
this._insertQuotedMessage(payload.event);
break;
case 'insert_emoji':
this._insertEmoji(payload.emoji);
break;
}
};
@ -414,9 +413,22 @@ export default class SendMessageComposer extends React.Component {
this._editorRef && this._editorRef.focus();
}
_insertEmoji = (emoji) => {
const {model} = this;
const {partCreator} = model;
const caret = this._editorRef.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
model.transform(() => {
const addedLen = model.insert([partCreator.plain(emoji)], position);
return model.positionForOffset(caret.offset + addedLen, true);
});
};
_onPaste = (event) => {
const {clipboardData} = event;
if (clipboardData.files.length) {
// Prioritize text on the clipboard over files as Office on macOS puts a bitmap
// in the clipboard as well as the content being copied.
if (clipboardData.files.length && !clipboardData.types.some(t => t === "text/plain")) {
// This actually not so much for 'files' as such (at time of writing
// neither chrome nor firefox let you paste a plain file copied
// from Finder) but more images copied from a different website
@ -424,15 +436,13 @@ export default class SendMessageComposer extends React.Component {
ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(clipboardData.files), this.props.room.roomId, this.context,
);
return true; // to skip internal onPaste handler
}
}
render() {
return (
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}>
<div className="mx_SendMessageComposer_overlayWrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
</div>
<BasicMessageComposer
ref={this._setEditorRef}
model={this.model}
@ -440,6 +450,7 @@ export default class SendMessageComposer extends React.Component {
label={this.props.placeholder}
placeholder={this.props.placeholder}
onChange={this._saveStoredEditorState}
onPaste={this._onPaste}
/>
</div>
);

View file

@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import {_t, _td} from '../../../languageHandler';
import AppTile from '../elements/AppTile';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import dis from '../../../dispatcher';
import dis from '../../../dispatcher/dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
import WidgetUtils from '../../../utils/WidgetUtils';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
@ -27,6 +28,8 @@ import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import {ContextMenu} from "../../structures/ContextMenu";
import {WidgetType} from "../../../widgets/WidgetType";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {Action} from "../../../dispatcher/actions";
// 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.
@ -180,7 +183,7 @@ export default class Stickerpicker extends React.Component {
case "stickerpicker_close":
this.setState({showStickers: false});
break;
case "after_right_panel_phase_change":
case Action.AfterRightPanelPhaseChange:
case "show_left_panel":
case "hide_left_panel":
this.setState({showStickers: false});
@ -360,7 +363,7 @@ export default class Stickerpicker extends React.Component {
*/
_launchManageIntegrations() {
// TODO: Open the right integration manager for the widget
if (SettingsStore.isFeatureEnabled("feature_many_integration_managers")) {
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(
this.props.room,
`type_${WidgetType.STICKERPICKER.preferred}`,
@ -378,14 +381,21 @@ export default class Stickerpicker extends React.Component {
render() {
let stickerPicker;
let stickersButton;
const className = classNames(
"mx_MessageComposer_button",
"mx_MessageComposer_stickers",
"mx_Stickers_hideStickers",
"mx_MessageComposer_button_highlight",
);
if (this.state.showStickers) {
// Show hide-stickers button
stickersButton =
<AccessibleButton
id='stickersButton'
key="controls_hide_stickers"
className="mx_MessageComposer_button mx_MessageComposer_stickers mx_Stickers_hideStickers"
className={className}
onClick={this._onHideStickersClick}
active={this.state.showStickers}
title={_t("Hide Stickers")}
>
</AccessibleButton>;
@ -409,14 +419,14 @@ export default class Stickerpicker extends React.Component {
} else {
// Show show-stickers button
stickersButton =
<AccessibleButton
<AccessibleTooltipButton
id='stickersButton'
key="controls_show_stickers"
className="mx_MessageComposer_button mx_MessageComposer_stickers"
onClick={this._onShowStickersClick}
title={_t("Show Stickers")}
>
</AccessibleButton>;
</AccessibleTooltipButton>;
}
return <React.Fragment>
{ stickersButton }

View file

@ -0,0 +1,119 @@
/*
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 classNames from "classnames";
import {
RovingAccessibleButton,
RovingAccessibleTooltipButton,
RovingTabIndexWrapper
} from "../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../views/elements/AccessibleButton";
import NotificationBadge from "./NotificationBadge";
import { NotificationState } from "../../../stores/notifications/NotificationState";
interface IProps {
isMinimized: boolean;
isSelected: boolean;
displayName: string;
avatar: React.ReactElement;
notificationState: NotificationState;
onClick: () => void;
}
interface IState {
hover: boolean;
}
// TODO: Remove with community invites in the room list: https://github.com/vector-im/element-web/issues/14456
export default class TemporaryTile extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
hover: false,
};
}
private onTileMouseEnter = () => {
this.setState({hover: true});
};
private onTileMouseLeave = () => {
this.setState({hover: false});
};
public render(): React.ReactElement {
// XXX: We copy classes because it's easier
const classes = classNames({
'mx_RoomTile': true,
'mx_TemporaryTile': true,
'mx_RoomTile_selected': this.props.isSelected,
'mx_RoomTile_minimized': this.props.isMinimized,
});
const badge = (
<NotificationBadge
notification={this.props.notificationState}
forceCount={false}
/>
);
let name = this.props.displayName;
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_nameHasUnreadEvents": this.props.notificationState.isUnread,
});
let nameContainer = (
<div className="mx_RoomTile_nameContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
</div>
</div>
);
if (this.props.isMinimized) nameContainer = null;
let Button = RovingAccessibleButton;
if (this.props.isMinimized) {
Button = RovingAccessibleTooltipButton;
}
return (
<React.Fragment>
<Button
className={classes}
onMouseEnter={this.onTileMouseEnter}
onMouseLeave={this.onTileMouseLeave}
onClick={this.props.onClick}
role="treeitem"
title={this.props.isMinimized ? name : undefined}
>
<div className="mx_RoomTile_avatarContainer">
{this.props.avatar}
</div>
{nameContainer}
<div className="mx_RoomTile_badgeContainer">
{badge}
</div>
</Button>
</React.Fragment>
);
}
}

View file

@ -19,7 +19,7 @@ import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {MatrixEvent} from "matrix-js-sdk";
import {_t} from "../../../languageHandler";
import dis from "../../../dispatcher";
import dis from "../../../dispatcher/dispatcher";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import {isValid3pidInvite} from "../../../RoomInvite";

View file

@ -1,48 +0,0 @@
/*
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;