Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into SuppressSpeechWhenSending
This commit is contained in:
commit
c1d2e27f9c
239 changed files with 4980 additions and 10446 deletions
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import React, {createRef, KeyboardEvent} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import flatMap from 'lodash/flatMap';
|
||||
import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter';
|
||||
|
|
|
@ -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({
|
||||
|
@ -142,7 +143,6 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const CallView = sdk.getComponent("voip.CallView");
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
let fileDropTarget = null;
|
||||
|
|
|
@ -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,45 +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,
|
||||
onPaste: PropTypes.func, // returns true if handled and should skip internal onPaste handler
|
||||
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,
|
||||
|
@ -139,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);
|
||||
|
@ -180,26 +216,28 @@ 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.
|
||||
|
||||
|
@ -213,48 +251,48 @@ 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("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");
|
||||
};
|
||||
|
||||
_onPaste = (event) => {
|
||||
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
|
||||
|
@ -273,28 +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);
|
||||
}
|
||||
};
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
|
@ -303,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() {
|
||||
|
@ -332,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() {
|
||||
|
@ -347,51 +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 = () => {
|
||||
private onSelectionChange = () => {
|
||||
const {isEmpty} = this.props.model;
|
||||
|
||||
this._refreshLastCaretIfNeeded();
|
||||
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 && !isEmpty) {
|
||||
this._hasTextSelected = true;
|
||||
if (this._formatBarRef) {
|
||||
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) ||
|
||||
|
@ -414,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,
|
||||
});
|
||||
|
@ -465,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;
|
||||
|
@ -500,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();
|
||||
|
@ -512,64 +550,58 @@ export default class BasicMessageEditor extends React.Component {
|
|||
}
|
||||
|
||||
isModified() {
|
||||
return this._modifiedFlag;
|
||||
return this.modifiedFlag;
|
||||
}
|
||||
|
||||
_onAutoCompleteConfirm = (completion) => {
|
||||
private onAutoCompleteConfirm = (completion: ICompletion) => {
|
||||
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.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,
|
||||
|
@ -583,34 +615,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;
|
||||
|
@ -619,10 +651,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}
|
||||
/>
|
||||
|
@ -635,7 +667,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"),
|
||||
|
@ -646,18 +677,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"
|
||||
|
@ -671,6 +702,6 @@ export default class BasicMessageEditor extends React.Component {
|
|||
}
|
||||
|
||||
focus() {
|
||||
this._editorRef.focus();
|
||||
this.editorRef.current.focus();
|
||||
}
|
||||
}
|
|
@ -66,7 +66,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip, bordere
|
|||
}
|
||||
|
||||
const onMouseOver = () => setHover(true);
|
||||
const onMouseOut = () => setHover(false);
|
||||
const onMouseLeave = () => setHover(false);
|
||||
|
||||
let tip;
|
||||
if (hover && !hideTooltip) {
|
||||
|
@ -78,7 +78,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip, bordere
|
|||
<AccessibleButton
|
||||
onClick={onClick}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={classes}
|
||||
style={style}
|
||||
>
|
||||
|
@ -87,7 +87,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip, bordere
|
|||
);
|
||||
}
|
||||
|
||||
return <div onMouseOver={onMouseOver} onMouseOut={onMouseOut} className={classes} style={style}>
|
||||
return <div onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} className={classes} style={style}>
|
||||
{ tip }
|
||||
</div>;
|
||||
};
|
||||
|
|
|
@ -333,7 +333,7 @@ export default createReactClass({
|
|||
return;
|
||||
}
|
||||
|
||||
const eventSenderTrust = this.context.checkDeviceTrust(
|
||||
const eventSenderTrust = encryptionInfo.sender && this.context.checkDeviceTrust(
|
||||
senderId, encryptionInfo.sender.deviceId,
|
||||
);
|
||||
if (!eventSenderTrust) {
|
||||
|
|
|
@ -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>);
|
||||
}
|
||||
}
|
|
@ -27,7 +27,8 @@ import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
|
|||
import ContentMessages from '../../../ContentMessages';
|
||||
import E2EIcon from './E2EIcon';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
|
||||
import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
function ComposerAvatar(props) {
|
||||
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
|
||||
|
@ -41,7 +42,6 @@ ComposerAvatar.propTypes = {
|
|||
};
|
||||
|
||||
function CallButton(props) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const onVoiceCallClick = (ev) => {
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
|
@ -50,10 +50,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 = {
|
||||
|
@ -61,7 +62,6 @@ CallButton.propTypes = {
|
|||
};
|
||||
|
||||
function VideoCallButton(props) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const onCallClick = (ev) => {
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
|
@ -70,7 +70,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')}
|
||||
/>;
|
||||
|
@ -117,14 +118,15 @@ const EmojiButton = ({addEmoji}) => {
|
|||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<ContextMenuButton className="mx_MessageComposer_button mx_MessageComposer_emoji"
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
label={_t('Emoji picker')}
|
||||
inputRef={button}
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageComposer_button mx_MessageComposer_emoji"
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
title={_t('Emoji picker')}
|
||||
inputRef={button}
|
||||
>
|
||||
|
||||
</ContextMenuButton>
|
||||
</ContextMenuTooltipButton>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
|
@ -185,9 +187,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')}
|
||||
>
|
||||
|
@ -198,7 +200,7 @@ class UploadButton extends React.Component {
|
|||
multiple
|
||||
onChange={this.onUploadFileInputChange}
|
||||
/>
|
||||
</AccessibleButton>
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -23,11 +23,10 @@ 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/RoomListStore2";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
|
||||
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
|
||||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
@ -43,7 +42,7 @@ interface IState {
|
|||
skipFirst: boolean;
|
||||
}
|
||||
|
||||
export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState> {
|
||||
export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState> {
|
||||
private isMounted = true;
|
||||
|
||||
constructor(props: IProps) {
|
||||
|
@ -86,13 +85,13 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
|
|||
const roomTags = RoomListStore.instance.getTagsForRoom(r);
|
||||
const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];
|
||||
return (
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomBreadcrumbs2_crumb"
|
||||
<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_RoomBreadcrumbs2_Tooltip"}
|
||||
tooltipClassName="mx_RoomBreadcrumbs_Tooltip"
|
||||
>
|
||||
<DecoratedRoomAvatar
|
||||
room={r}
|
||||
|
@ -101,7 +100,7 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
|
|||
displayBadge={true}
|
||||
forceCount={true}
|
||||
/>
|
||||
</AccessibleTooltipButton>
|
||||
</RovingAccessibleTooltipButton>
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -110,17 +109,17 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
|
|||
return (
|
||||
<CSSTransition
|
||||
appear={true} in={this.state.doAnimation} timeout={640}
|
||||
classNames='mx_RoomBreadcrumbs2'
|
||||
classNames='mx_RoomBreadcrumbs'
|
||||
>
|
||||
<div className='mx_RoomBreadcrumbs2'>
|
||||
<Toolbar className='mx_RoomBreadcrumbs'>
|
||||
{tiles.slice(this.state.skipFirst ? 1 : 0)}
|
||||
</div>
|
||||
</Toolbar>
|
||||
</CSSTransition>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className='mx_RoomBreadcrumbs2'>
|
||||
<div className="mx_RoomBreadcrumbs2_placeholder">
|
||||
<div className='mx_RoomBreadcrumbs'>
|
||||
<div className="mx_RoomBreadcrumbs_placeholder">
|
||||
{_t("No recently visited rooms")}
|
||||
</div>
|
||||
</div>
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -26,7 +26,6 @@ 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";
|
||||
|
@ -34,6 +33,7 @@ import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
|
|||
import E2EIcon from './E2EIcon';
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import {DefaultTagID} from "../../../stores/room-list/models";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoomHeader',
|
||||
|
@ -220,11 +220,10 @@ export default createReactClass({
|
|||
|
||||
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')) {
|
||||
|
@ -236,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 =
|
||||
|
|
|
@ -1,853 +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 CustomRoomTagStore from '../../../stores/CustomRoomTagStore';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
import RoomSubList from '../../structures/RoomSubList';
|
||||
import ResizeHandle from '../elements/ResizeHandle';
|
||||
import CallHandler from "../../../CallHandler";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import * as sdk from "../../../index";
|
||||
import * as Receipt from "../../../utils/Receipt";
|
||||
import {Resizer} from '../../../resizer';
|
||||
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
|
||||
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
|
||||
import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy";
|
||||
import {DefaultTagID} from "../../../stores/room-list/models";
|
||||
import * as Unread from "../../../Unread";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import {TAG_DM} from "../../../stores/RoomListStore";
|
||||
|
||||
const HIDE_CONFERENCE_CHANS = true;
|
||||
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
||||
const HOVER_MOVE_TIMEOUT = 1000;
|
||||
|
||||
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 = RoomListStoreTempProxy.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 = RoomListStoreTempProxy.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 = RoomListStoreTempProxy.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[DefaultTagID.DM],
|
||||
label: _t('Direct Messages'),
|
||||
tagName: DefaultTagID.DM,
|
||||
incomingCall: incomingCallIfTaggedAs(DefaultTagID.DM),
|
||||
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
|
||||
addRoomLabel: _t("Start chat"),
|
||||
},
|
||||
{
|
||||
list: this.state.lists['im.vector.fake.recent'],
|
||||
label: _t('Rooms'),
|
||||
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
|
||||
onAddRoom: () => {dis.dispatch({action: 'view_create_room'});},
|
||||
addRoomLabel: _t("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>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -23,13 +23,13 @@ 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/RoomListStore2";
|
||||
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, TagID } from "../../../stores/room-list/models";
|
||||
import { DefaultTagID, isCustomTag, TagID } from "../../../stores/room-list/models";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import RoomSublist2 from "./RoomSublist2";
|
||||
import RoomSublist from "./RoomSublist";
|
||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
|
@ -40,8 +40,8 @@ import { NotificationColor } from "../../../stores/notifications/NotificationCol
|
|||
import { Action } from "../../../dispatcher/actions";
|
||||
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import CustomRoomTagStore from "../../../stores/CustomRoomTagStore";
|
||||
|
||||
interface IProps {
|
||||
onKeyDown: (ev: React.KeyboardEvent) => void;
|
||||
|
@ -78,6 +78,7 @@ const ALWAYS_VISIBLE_TAGS: TagID[] = [
|
|||
|
||||
interface ITagAesthetics {
|
||||
sectionLabel: string;
|
||||
sectionLabelRaw?: string;
|
||||
addRoomLabel?: string;
|
||||
onAddRoom?: (dispatcher: Dispatcher<ActionPayload>) => void;
|
||||
isInvite: boolean;
|
||||
|
@ -131,9 +132,22 @@ const TAG_AESTHETICS: {
|
|||
},
|
||||
};
|
||||
|
||||
export default class RoomList2 extends React.Component<IProps, IState> {
|
||||
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.Component<IProps, IState> {
|
||||
private searchFilter: NameFilterCondition = new NameFilterCondition();
|
||||
private dispatcherRef;
|
||||
private customTagStoreRef;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -162,12 +176,14 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
|
||||
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) => {
|
||||
|
@ -194,7 +210,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
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, t);
|
||||
const state = RoomNotificationStateStore.instance.getRoomState(r);
|
||||
return state.room.roomId === roomId || state.isUnread;
|
||||
});
|
||||
}
|
||||
|
@ -210,8 +226,8 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
|
||||
private updateLists = () => {
|
||||
const newLists = RoomListStore.instance.orderedLists;
|
||||
if (window.mx_LoudRoomListLogging) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
|
||||
if (SettingsStore.getValue("advancedRoomListLogging")) {
|
||||
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
|
||||
console.log("new lists", newLists);
|
||||
}
|
||||
|
||||
|
@ -258,12 +274,18 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
private renderSublists(): React.ReactElement[] {
|
||||
const components: React.ReactElement[] = [];
|
||||
|
||||
for (const orderedTagId of TAG_ORDER) {
|
||||
if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
|
||||
// Populate custom tags if needed
|
||||
// TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091
|
||||
const tagOrder = TAG_ORDER.reduce((p, c) => {
|
||||
if (c === CUSTOM_TAGS_BEFORE_TAG) {
|
||||
const customTags = Object.keys(this.state.sublists)
|
||||
.filter(t => isCustomTag(t))
|
||||
.filter(t => CustomRoomTagStore.getTags()[t]); // isSelected
|
||||
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);
|
||||
|
@ -271,20 +293,22 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
continue; // skip tag - not needed
|
||||
}
|
||||
|
||||
const aesthetics: ITagAesthetics = TAG_AESTHETICS[orderedTagId];
|
||||
const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
|
||||
? customTagAesthetics(orderedTagId)
|
||||
: TAG_AESTHETICS[orderedTagId];
|
||||
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
|
||||
|
||||
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
|
||||
components.push(
|
||||
<RoomSublist2
|
||||
<RoomSublist
|
||||
key={`sublist-${orderedTagId}`}
|
||||
tagId={orderedTagId}
|
||||
forRooms={true}
|
||||
rooms={orderedRooms}
|
||||
startAsHidden={aesthetics.defaultHidden}
|
||||
label={_t(aesthetics.sectionLabel)}
|
||||
label={aesthetics.sectionLabelRaw ? aesthetics.sectionLabelRaw : _t(aesthetics.sectionLabel)}
|
||||
onAddRoom={onAddRoomFn}
|
||||
addRoomLabel={aesthetics.addRoomLabel}
|
||||
addRoomLabel={aesthetics.addRoomLabel ? _t(aesthetics.addRoomLabel) : aesthetics.addRoomLabel}
|
||||
isMinimized={this.props.isMinimized}
|
||||
onResize={this.props.onResize}
|
||||
extraBadTilesThatShouldntExist={extraTiles}
|
||||
|
@ -305,7 +329,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
onFocus={this.props.onFocus}
|
||||
onBlur={this.props.onBlur}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
className="mx_RoomList2"
|
||||
className="mx_RoomList"
|
||||
role="tree"
|
||||
aria-label={_t("Rooms")}
|
||||
>{sublists}</div>
|
|
@ -23,16 +23,16 @@ import classNames from 'classnames';
|
|||
import { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import RoomTile2 from "./RoomTile2";
|
||||
import RoomTile from "./RoomTile";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
import {
|
||||
ChevronFace,
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
ContextMenuTooltipButton,
|
||||
StyledMenuItemCheckbox,
|
||||
StyledMenuItemRadio,
|
||||
} from "../../structures/ContextMenu";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore2";
|
||||
import RoomListStore 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";
|
||||
|
@ -48,8 +48,6 @@ import { polyfillTouchEvent } from "../../../@types/polyfill";
|
|||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
|
||||
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
|
||||
|
||||
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
|
||||
|
@ -94,7 +92,7 @@ interface IState {
|
|||
height: number;
|
||||
}
|
||||
|
||||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||
export default class RoomSublist extends React.Component<IProps, IState> {
|
||||
private headerButton = createRef<HTMLDivElement>();
|
||||
private sublistRef = createRef<HTMLDivElement>();
|
||||
private dispatcherRef: string;
|
||||
|
@ -144,7 +142,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
private get numTiles(): number {
|
||||
return RoomSublist2.calcNumTiles(this.props);
|
||||
return RoomSublist.calcNumTiles(this.props);
|
||||
}
|
||||
|
||||
private static calcNumTiles(props) {
|
||||
|
@ -167,7 +165,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
}
|
||||
// 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 (RoomSublist2.calcNumTiles(prevProps) !== this.numTiles) {
|
||||
if (RoomSublist.calcNumTiles(prevProps) !== this.numTiles) {
|
||||
this.setState({height: this.calculateInitialHeight()});
|
||||
}
|
||||
}
|
||||
|
@ -252,7 +250,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
|
||||
private focusRoomTile = (index: number) => {
|
||||
if (!this.sublistRef.current) return;
|
||||
const elements = this.sublistRef.current.querySelectorAll<HTMLDivElement>(".mx_RoomTile2");
|
||||
const elements = this.sublistRef.current.querySelectorAll<HTMLDivElement>(".mx_RoomTile");
|
||||
const element = elements && elements[index];
|
||||
if (element) {
|
||||
element.focus();
|
||||
|
@ -326,11 +324,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
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 LeftPanel2, the top header is always sticky
|
||||
// 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_RoomSublist2_headerContainer_stickyTop');
|
||||
const isStickyBottom = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_stickyBottom');
|
||||
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
|
||||
|
@ -370,7 +368,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
this.toggleCollapsed();
|
||||
} else if (this.sublistRef.current) {
|
||||
// otherwise focus the first room
|
||||
const element = this.sublistRef.current.querySelector(".mx_RoomTile2") as HTMLDivElement;
|
||||
const element = this.sublistRef.current.querySelector(".mx_RoomTile") as HTMLDivElement;
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
|
@ -405,7 +403,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles);
|
||||
for (const room of visibleRooms) {
|
||||
tiles.push(
|
||||
<RoomTile2
|
||||
<RoomTile
|
||||
room={room}
|
||||
key={`room-${room.roomId}`}
|
||||
showMessagePreview={this.layout.showPreviews}
|
||||
|
@ -444,24 +442,20 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
<React.Fragment>
|
||||
<hr />
|
||||
<div>
|
||||
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
|
||||
<div className='mx_RoomSublist_contextMenu_title'>{_t("Appearance")}</div>
|
||||
<StyledMenuItemCheckbox
|
||||
onClose={this.onCloseMenu}
|
||||
onChange={this.onUnreadFirstChanged}
|
||||
checked={isUnreadFirst}
|
||||
>
|
||||
{_t("Always show first")}
|
||||
{_t("Show rooms with unread messages first")}
|
||||
</StyledMenuItemCheckbox>
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
|
||||
<StyledMenuItemCheckbox
|
||||
onClose={this.onCloseMenu}
|
||||
onChange={this.onMessagePreviewChanged}
|
||||
checked={this.layout.showPreviews}
|
||||
>
|
||||
{_t("Message preview")}
|
||||
{_t("Show previews of messages")}
|
||||
</StyledMenuItemCheckbox>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
|
@ -475,9 +469,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
|
||||
onFinished={this.onCloseMenu}
|
||||
>
|
||||
<div className="mx_RoomSublist2_contextMenu">
|
||||
<div className="mx_RoomSublist_contextMenu">
|
||||
<div>
|
||||
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div>
|
||||
<div className='mx_RoomSublist_contextMenu_title'>{_t("Sort by")}</div>
|
||||
<StyledMenuItemRadio
|
||||
onClose={this.onCloseMenu}
|
||||
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
|
||||
|
@ -503,10 +497,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
className="mx_RoomSublist2_menuButton"
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_RoomSublist_menuButton"
|
||||
onClick={this.onOpenMenuClick}
|
||||
label={_t("List options")}
|
||||
title={_t("List options")}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
/>
|
||||
{contextMenu}
|
||||
|
@ -541,30 +535,35 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
<AccessibleTooltipButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={this.onAddRoom}
|
||||
className="mx_RoomSublist2_auxButton"
|
||||
className="mx_RoomSublist_auxButton"
|
||||
aria-label={this.props.addRoomLabel || _t("Add room")}
|
||||
title={this.props.addRoomLabel}
|
||||
tooltipClassName={"mx_RoomSublist2_addRoomTooltip"}
|
||||
tooltipClassName={"mx_RoomSublist_addRoomTooltip"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const collapseClasses = classNames({
|
||||
'mx_RoomSublist2_collapseBtn': true,
|
||||
'mx_RoomSublist2_collapseBtn_collapsed': !this.state.isExpanded,
|
||||
'mx_RoomSublist_collapseBtn': true,
|
||||
'mx_RoomSublist_collapseBtn_collapsed': !this.state.isExpanded,
|
||||
});
|
||||
|
||||
const classes = classNames({
|
||||
'mx_RoomSublist2_headerContainer': true,
|
||||
'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton,
|
||||
'mx_RoomSublist_headerContainer': true,
|
||||
'mx_RoomSublist_headerContainer_withAux': !!addRoomButton,
|
||||
});
|
||||
|
||||
const badgeContainer = (
|
||||
<div className="mx_RoomSublist2_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
|
||||
|
@ -572,21 +571,22 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
// The same applies to the notification badge.
|
||||
return (
|
||||
<div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus} aria-label={this.props.label}>
|
||||
<div className="mx_RoomSublist2_stickable">
|
||||
<AccessibleButton
|
||||
<div className="mx_RoomSublist_stickable">
|
||||
<Button
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={tabIndex}
|
||||
className="mx_RoomSublist2_headerText"
|
||||
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>
|
||||
</AccessibleButton>
|
||||
</Button>
|
||||
{this.renderMenu()}
|
||||
{this.props.isMinimized ? null : badgeContainer}
|
||||
{this.props.isMinimized ? null : addRoomButton}
|
||||
|
@ -609,9 +609,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
public render(): React.ReactElement {
|
||||
const visibleTiles = this.renderVisibleTiles();
|
||||
const classes = classNames({
|
||||
'mx_RoomSublist2': true,
|
||||
'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition,
|
||||
'mx_RoomSublist2_minimized': this.props.isMinimized,
|
||||
'mx_RoomSublist': true,
|
||||
'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition,
|
||||
'mx_RoomSublist_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
let content = null;
|
||||
|
@ -624,7 +624,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding);
|
||||
let maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
|
||||
const showMoreBtnClasses = classNames({
|
||||
'mx_RoomSublist2_showNButton': true,
|
||||
'mx_RoomSublist_showNButton': true,
|
||||
});
|
||||
|
||||
// If we're hiding rooms, show a 'show more' button to the user. This button
|
||||
|
@ -638,14 +638,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight);
|
||||
const numMissing = this.numTiles - amountFullyShown;
|
||||
let showMoreText = (
|
||||
<span className='mx_RoomSublist2_showNButtonText'>
|
||||
<span className='mx_RoomSublist_showNButtonText'>
|
||||
{_t("Show %(count)s more", {count: numMissing})}
|
||||
</span>
|
||||
);
|
||||
if (this.props.isMinimized) showMoreText = null;
|
||||
showNButton = (
|
||||
<RovingAccessibleButton onClick={this.onShowAllClick} className={showMoreBtnClasses}>
|
||||
<span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'>
|
||||
<span className='mx_RoomSublist_showMoreButtonChevron mx_RoomSublist_showNButtonChevron'>
|
||||
{/* set by CSS masking */}
|
||||
</span>
|
||||
{showMoreText}
|
||||
|
@ -654,14 +654,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
} else if (this.numTiles > this.layout.defaultVisibleTiles) {
|
||||
// we have all tiles visible - add a button to show less
|
||||
let showLessText = (
|
||||
<span className='mx_RoomSublist2_showNButtonText'>
|
||||
<span className='mx_RoomSublist_showNButtonText'>
|
||||
{_t("Show less")}
|
||||
</span>
|
||||
);
|
||||
if (this.props.isMinimized) showLessText = null;
|
||||
showNButton = (
|
||||
<RovingAccessibleButton onClick={this.onShowLessClick} className={showMoreBtnClasses}>
|
||||
<span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'>
|
||||
<span className='mx_RoomSublist_showLessButtonChevron mx_RoomSublist_showNButtonChevron'>
|
||||
{/* set by CSS masking */}
|
||||
</span>
|
||||
{showLessText}
|
||||
|
@ -696,8 +696,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
// only mathematically 7 possible).
|
||||
|
||||
const handleWrapperClasses = classNames({
|
||||
'mx_RoomSublist2_resizerHandles': true,
|
||||
'mx_RoomSublist2_resizerHandles_showNButton': !!showNButton,
|
||||
'mx_RoomSublist_resizerHandles': true,
|
||||
'mx_RoomSublist_resizerHandles_showNButton': !!showNButton,
|
||||
});
|
||||
|
||||
content = (
|
||||
|
@ -710,11 +710,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
onResizeStop={this.onResizeStop}
|
||||
onResize={this.onResize}
|
||||
handleWrapperClass={handleWrapperClasses}
|
||||
handleClasses={{bottom: "mx_RoomSublist2_resizerHandle"}}
|
||||
className="mx_RoomSublist2_resizeBox"
|
||||
handleClasses={{bottom: "mx_RoomSublist_resizerHandle"}}
|
||||
className="mx_RoomSublist_resizeBox"
|
||||
enable={handles}
|
||||
>
|
||||
<div className="mx_RoomSublist2_tiles" onScroll={this.onScrollPrevent}>
|
||||
<div className="mx_RoomSublist_tiles" onScroll={this.onScrollPrevent}>
|
||||
{visibleTiles}
|
||||
</div>
|
||||
{showNButton}
|
|
@ -1,565 +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/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;
|
||||
}
|
||||
|
||||
/* 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 (typeof name !== 'string') name = '';
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
let badge;
|
||||
if (badges) {
|
||||
const limitedCount = FormattingUtils.formatCount(notificationCount);
|
||||
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 (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>;
|
||||
},
|
||||
});
|
|
@ -29,7 +29,7 @@ import { _t } from "../../../languageHandler";
|
|||
import {
|
||||
ChevronFace,
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
ContextMenuTooltipButton,
|
||||
MenuItemRadio,
|
||||
MenuItemCheckbox,
|
||||
MenuItem,
|
||||
|
@ -48,14 +48,13 @@ import {
|
|||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { Volume } from "../../../RoomNotifsTypes";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore2";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore";
|
||||
import RoomListActions from "../../../actions/RoomListActions";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {ActionPayload} from "../../../dispatcher/payloads";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
@ -76,7 +75,7 @@ interface IState {
|
|||
generalMenuPosition: PartialDOMRect;
|
||||
}
|
||||
|
||||
const messagePreviewId = (roomId: string) => `mx_RoomTile2_messagePreview_${roomId}`;
|
||||
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
|
||||
|
@ -95,12 +94,12 @@ interface INotifOptionProps {
|
|||
|
||||
const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassName, label}) => {
|
||||
const classes = classNames({
|
||||
mx_RoomTile2_contextMenu_activeRow: active,
|
||||
mx_RoomTile_contextMenu_activeRow: active,
|
||||
});
|
||||
|
||||
let activeIcon;
|
||||
if (active) {
|
||||
activeIcon = <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconCheck" />;
|
||||
activeIcon = <span className="mx_IconizedContextMenu_icon mx_RoomTile_iconCheck" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -112,7 +111,7 @@ const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassNam
|
|||
);
|
||||
};
|
||||
|
||||
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||
export default class RoomTile extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private roomTileRef = createRef<HTMLDivElement>();
|
||||
|
||||
|
@ -121,7 +120,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
|
||||
this.state = {
|
||||
hover: false,
|
||||
notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag),
|
||||
notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room),
|
||||
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
|
||||
notificationsMenuPosition: null,
|
||||
generalMenuPosition: null,
|
||||
|
@ -232,11 +231,11 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (tagId === DefaultTagID.Favourite) {
|
||||
const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room);
|
||||
const isFavourite = roomTags.includes(DefaultTagID.Favourite);
|
||||
const removeTag = isFavourite ? DefaultTagID.Favourite : DefaultTagID.LowPriority;
|
||||
const addTag = isFavourite ? null : DefaultTagID.Favourite;
|
||||
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,
|
||||
|
@ -327,30 +326,30 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
if (this.state.notificationsMenuPosition) {
|
||||
contextMenu = (
|
||||
<ContextMenu {...contextMenuBelow(this.state.notificationsMenuPosition)} onFinished={this.onCloseNotificationsMenu}>
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile_contextMenu">
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<NotifOption
|
||||
label={_t("Use default")}
|
||||
active={state === ALL_MESSAGES}
|
||||
iconClassName="mx_RoomTile2_iconBell"
|
||||
iconClassName="mx_RoomTile_iconBell"
|
||||
onClick={this.onClickAllNotifs}
|
||||
/>
|
||||
<NotifOption
|
||||
label={_t("All messages")}
|
||||
active={state === ALL_MESSAGES_LOUD}
|
||||
iconClassName="mx_RoomTile2_iconBellDot"
|
||||
iconClassName="mx_RoomTile_iconBellDot"
|
||||
onClick={this.onClickAlertMe}
|
||||
/>
|
||||
<NotifOption
|
||||
label={_t("Mentions & Keywords")}
|
||||
active={state === MENTIONS_ONLY}
|
||||
iconClassName="mx_RoomTile2_iconBellMentions"
|
||||
iconClassName="mx_RoomTile_iconBellMentions"
|
||||
onClick={this.onClickMentions}
|
||||
/>
|
||||
<NotifOption
|
||||
label={_t("None")}
|
||||
active={state === MUTE}
|
||||
iconClassName="mx_RoomTile2_iconBellCrossed"
|
||||
iconClassName="mx_RoomTile_iconBellCrossed"
|
||||
onClick={this.onClickMute}
|
||||
/>
|
||||
</div>
|
||||
|
@ -359,24 +358,24 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
const classes = classNames("mx_RoomTile2_notificationsButton", {
|
||||
const classes = classNames("mx_RoomTile_notificationsButton", {
|
||||
// Show bell icon for the default case too.
|
||||
mx_RoomTile2_iconBell: state === ALL_MESSAGES,
|
||||
mx_RoomTile2_iconBellDot: state === ALL_MESSAGES_LOUD,
|
||||
mx_RoomTile2_iconBellMentions: state === MENTIONS_ONLY,
|
||||
mx_RoomTile2_iconBellCrossed: state === MUTE,
|
||||
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_RoomTile2_notificationsButton_show: state === MUTE,
|
||||
mx_RoomTile_notificationsButton_show: state === MUTE,
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
<ContextMenuTooltipButton
|
||||
className={classes}
|
||||
onClick={this.onNotificationsMenuOpenClick}
|
||||
label={_t("Notification options")}
|
||||
title={_t("Notification options")}
|
||||
isExpanded={!!this.state.notificationsMenuPosition}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
|
@ -388,21 +387,14 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
private renderGeneralMenu(): React.ReactElement {
|
||||
if (!this.showContextMenu) return null; // no menu to show
|
||||
|
||||
const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room);
|
||||
|
||||
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
|
||||
const favouriteIconClassName = isFavorite ? "mx_RoomTile2_iconFavorite" : "mx_RoomTile2_iconStar";
|
||||
const favouriteLabelClassName = isFavorite ? "mx_RoomTile2_contextMenu_activeRow" : "";
|
||||
const favouriteLabel = isFavorite ? _t("Favourited") : _t("Favourite");
|
||||
|
||||
let contextMenu = null;
|
||||
if (this.state.generalMenuPosition && this.props.tag === DefaultTagID.Archived) {
|
||||
contextMenu = (
|
||||
<ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
|
||||
<div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow">
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile_contextMenu">
|
||||
<div className="mx_IconizedContextMenu_optionList mx_RoomTile_contextMenu_redRow">
|
||||
<MenuItem onClick={this.onForgetRoomClick} label={_t("Leave Room")}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile_iconSignOut" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Forget Room")}</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
|
@ -410,27 +402,44 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
</ContextMenu>
|
||||
);
|
||||
} 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 = (
|
||||
<ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile_contextMenu">
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<MenuItemCheckbox
|
||||
className={favouriteLabelClassName}
|
||||
className={isFavorite ? "mx_RoomTile_contextMenu_activeRow" : ""}
|
||||
onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}
|
||||
active={isFavorite}
|
||||
label={favouriteLabel}
|
||||
>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", favouriteIconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile_iconStar" />
|
||||
<span className="mx_IconizedContextMenu_label">{favouriteLabel}</span>
|
||||
</MenuItemCheckbox>
|
||||
<MenuItemCheckbox
|
||||
className={isLowPriority ? "mx_RoomTile_contextMenu_activeRow" : ""}
|
||||
onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}
|
||||
active={isLowPriority}
|
||||
label={lowPriorityLabel}
|
||||
>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile_iconArrowDown" />
|
||||
<span className="mx_IconizedContextMenu_label">{lowPriorityLabel}</span>
|
||||
</MenuItemCheckbox>
|
||||
<MenuItem onClick={this.onOpenRoomSettings} label={_t("Settings")}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile_iconSettings" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Settings")}</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
<div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow">
|
||||
<div className="mx_IconizedContextMenu_optionList mx_RoomTile_contextMenu_redRow">
|
||||
<MenuItem onClick={this.onLeaveRoomClick} label={_t("Leave Room")}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile_iconSignOut" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
|
@ -441,10 +450,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
className="mx_RoomTile2_menuButton"
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_RoomTile_menuButton"
|
||||
onClick={this.onGeneralMenuOpenClick}
|
||||
label={_t("Room options")}
|
||||
title={_t("Room options")}
|
||||
isExpanded={!!this.state.generalMenuPosition}
|
||||
/>
|
||||
{contextMenu}
|
||||
|
@ -454,10 +463,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
|
||||
public render(): React.ReactElement {
|
||||
const classes = classNames({
|
||||
'mx_RoomTile2': true,
|
||||
'mx_RoomTile2_selected': this.state.selected,
|
||||
'mx_RoomTile2_hasMenuOpen': !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
|
||||
'mx_RoomTile2_minimized': this.props.isMinimized,
|
||||
'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
|
||||
|
@ -471,7 +480,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
if (!this.props.isMinimized) {
|
||||
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
|
||||
badge = (
|
||||
<div className="mx_RoomTile2_badgeContainer" aria-hidden="true">
|
||||
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
|
||||
<NotificationBadge
|
||||
notification={this.state.notificationState}
|
||||
forceCount={false}
|
||||
|
@ -493,7 +502,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
// Only show the preview if there is one to show.
|
||||
if (text) {
|
||||
messagePreview = (
|
||||
<div className="mx_RoomTile2_messagePreview" id={messagePreviewId(this.props.room.roomId)}>
|
||||
<div className="mx_RoomTile_messagePreview" id={messagePreviewId(this.props.room.roomId)}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
@ -501,13 +510,13 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const nameClasses = classNames({
|
||||
"mx_RoomTile2_name": true,
|
||||
"mx_RoomTile2_nameWithPreview": !!messagePreview,
|
||||
"mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.isUnread,
|
||||
"mx_RoomTile_name": true,
|
||||
"mx_RoomTile_nameWithPreview": !!messagePreview,
|
||||
"mx_RoomTile_nameHasUnreadEvents": this.state.notificationState.isUnread,
|
||||
});
|
||||
|
||||
let nameContainer = (
|
||||
<div className="mx_RoomTile2_nameContainer">
|
||||
<div className="mx_RoomTile_nameContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
|
@ -537,11 +546,16 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
ariaDescribedBy = messagePreviewId(this.props.room.roomId);
|
||||
}
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
if (this.props.isMinimized) {
|
||||
Button = AccessibleTooltipButton;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
|
||||
{({onFocus, isActive, ref}) =>
|
||||
<AccessibleButton
|
||||
<Button
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
inputRef={ref}
|
||||
|
@ -554,13 +568,14 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
aria-label={ariaLabel}
|
||||
aria-selected={this.state.selected}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
title={this.props.isMinimized ? name : undefined}
|
||||
>
|
||||
{roomAvatar}
|
||||
{nameContainer}
|
||||
{badge}
|
||||
{this.renderGeneralMenu()}
|
||||
{this.renderNotificationsMenu(isActive)}
|
||||
</AccessibleButton>
|
||||
</Button>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
</React.Fragment>
|
|
@ -22,6 +22,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
|||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { isPresenceEnabled } from "../../../utils/presence";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
|
||||
enum Icon {
|
||||
// Note: the names here are used in CSS class names
|
||||
|
@ -32,9 +34,21 @@ enum Icon {
|
|||
PresenceOffline = "OFFLINE",
|
||||
}
|
||||
|
||||
function tooltipText(variant: Icon) {
|
||||
switch (variant) {
|
||||
case Icon.Globe:
|
||||
return _t("This room is public");
|
||||
case Icon.PresenceOnline:
|
||||
return _t("Online");
|
||||
case Icon.PresenceAway:
|
||||
return _t("Away");
|
||||
case Icon.PresenceOffline:
|
||||
return _t("Offline");
|
||||
}
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
tag: TagID;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -122,10 +136,11 @@ export default class RoomTileIcon extends React.Component<IProps, IState> {
|
|||
private calculateIcon(): Icon {
|
||||
let icon = Icon.None;
|
||||
|
||||
if (this.props.tag === DefaultTagID.DM && this.props.room.getJoinedMemberCount() === 2) {
|
||||
// We look at the DMRoomMap and not the tag here so that we don't exclude DMs in Favourites
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
|
||||
if (otherUserId && this.props.room.getJoinedMemberCount() === 2) {
|
||||
// Track presence, if available
|
||||
if (isPresenceEnabled()) {
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
|
||||
if (otherUserId) {
|
||||
this.dmUser = MatrixClientPeg.get().getUser(otherUserId);
|
||||
icon = this.getPresenceIcon();
|
||||
|
@ -145,6 +160,9 @@ export default class RoomTileIcon extends React.Component<IProps, IState> {
|
|||
public render(): React.ReactElement {
|
||||
if (this.state.icon === Icon.None) return null;
|
||||
|
||||
return <span className={`mx_RoomTileIcon mx_RoomTileIcon_${this.state.icon.toLowerCase()}`} />;
|
||||
return <TextWithTooltip
|
||||
tooltip={tooltipText(this.state.icon)}
|
||||
class={`mx_RoomTileIcon mx_RoomTileIcon_${this.state.icon.toLowerCase()}`}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ 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";
|
||||
|
||||
// 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.
|
||||
|
@ -409,14 +410,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 }
|
||||
|
|
|
@ -16,7 +16,11 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import {
|
||||
RovingAccessibleButton,
|
||||
RovingAccessibleTooltipButton,
|
||||
RovingTabIndexWrapper
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
|
@ -55,10 +59,10 @@ export default class TemporaryTile extends React.Component<IProps, IState> {
|
|||
public render(): React.ReactElement {
|
||||
// XXX: We copy classes because it's easier
|
||||
const classes = classNames({
|
||||
'mx_RoomTile2': true,
|
||||
'mx_RoomTile': true,
|
||||
'mx_TemporaryTile': true,
|
||||
'mx_RoomTile2_selected': this.props.isSelected,
|
||||
'mx_RoomTile2_minimized': this.props.isMinimized,
|
||||
'mx_RoomTile_selected': this.props.isSelected,
|
||||
'mx_RoomTile_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
const badge = (
|
||||
|
@ -73,12 +77,12 @@ export default class TemporaryTile extends React.Component<IProps, IState> {
|
|||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
const nameClasses = classNames({
|
||||
"mx_RoomTile2_name": true,
|
||||
"mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.isUnread,
|
||||
"mx_RoomTile_name": true,
|
||||
"mx_RoomTile_nameHasUnreadEvents": this.props.notificationState.isUnread,
|
||||
});
|
||||
|
||||
let nameContainer = (
|
||||
<div className="mx_RoomTile2_nameContainer">
|
||||
<div className="mx_RoomTile_nameContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
|
@ -86,30 +90,29 @@ export default class TemporaryTile extends React.Component<IProps, IState> {
|
|||
);
|
||||
if (this.props.isMinimized) nameContainer = null;
|
||||
|
||||
let Button = RovingAccessibleButton;
|
||||
if (this.props.isMinimized) {
|
||||
Button = RovingAccessibleTooltipButton;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RovingTabIndexWrapper>
|
||||
{({onFocus, isActive, ref}) =>
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
inputRef={ref}
|
||||
className={classes}
|
||||
onMouseEnter={this.onTileMouseEnter}
|
||||
onMouseLeave={this.onTileMouseLeave}
|
||||
onClick={this.props.onClick}
|
||||
role="treeitem"
|
||||
>
|
||||
<div className="mx_RoomTile2_avatarContainer">
|
||||
{this.props.avatar}
|
||||
</div>
|
||||
{nameContainer}
|
||||
<div className="mx_RoomTile2_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
Loading…
Add table
Add a link
Reference in a new issue