Merge remote-tracking branch 'upstream/develop' into feature/image-view-load-anim/18186

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-09-11 15:32:44 +02:00
commit 1a128c3a00
No known key found for this signature in database
GPG key ID: 55C211A1226CB17D
567 changed files with 23292 additions and 9067 deletions

View file

@ -25,7 +25,6 @@ import SettingsStore from "../../../settings/SettingsStore";
import Autocompleter from '../../../autocomplete/Autocompleter';
import { replaceableComponent } from "../../../utils/replaceableComponent";
const COMPOSER_SELECTED = 0;
const MAX_PROVIDER_MATCHES = 20;
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
@ -34,9 +33,9 @@ interface IProps {
// the query string for which to show autocomplete suggestions
query: string;
// method invoked with range and text content when completion is confirmed
onConfirm: (ICompletion) => void;
onConfirm: (completion: ICompletion) => void;
// method invoked when selected (if any) completion changes
onSelectionChange?: (ICompletion, number) => void;
onSelectionChange?: (partIndex: number) => void;
selection: ISelectionRange;
// The room in which we're autocompleting
room: Room;
@ -71,7 +70,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
completionList: [],
// how far down the completion list we are (THIS IS 1-INDEXED!)
selectionOffset: COMPOSER_SELECTED,
selectionOffset: 1,
// whether we should show completions if they're available
shouldShowCompletions: true,
@ -86,7 +85,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
this.applyNewProps();
}
private applyNewProps(oldQuery?: string, oldRoom?: Room) {
private applyNewProps(oldQuery?: string, oldRoom?: Room): void {
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
this.autocompleter.destroy();
this.autocompleter = new Autocompleter(this.props.room);
@ -104,7 +103,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
this.autocompleter.destroy();
}
complete(query: string, selection: ISelectionRange) {
private complete(query: string, selection: ISelectionRange): Promise<void> {
this.queryRequested = query;
if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest);
@ -115,7 +114,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
completions: [],
completionList: [],
// Reset selected completion
selectionOffset: COMPOSER_SELECTED,
selectionOffset: 1,
// Hide the autocomplete box
hide: true,
});
@ -135,7 +134,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
}
processQuery(query: string, selection: ISelectionRange) {
private processQuery(query: string, selection: ISelectionRange): Promise<void> {
return this.autocompleter.getCompletions(
query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES,
).then((completions) => {
@ -147,30 +146,35 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
}
processCompletions(completions: IProviderCompletions[]) {
private processCompletions(completions: IProviderCompletions[]): void {
const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty.
let selectionOffset = COMPOSER_SELECTED;
let selectionOffset = 1;
if (completionList.length > 0) {
/* If the currently selected completion is still in the completion list,
try to find it and jump to it. If not, select composer.
*/
const currentSelection = this.state.selectionOffset === 0 ? null :
const currentSelection = this.state.selectionOffset <= 1 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex(
(completion) => completion.completion === currentSelection);
if (selectionOffset === -1) {
selectionOffset = COMPOSER_SELECTED;
selectionOffset = 1;
} else {
selectionOffset++; // selectionOffset is 1-indexed!
}
}
let hide = this.state.hide;
let hide = true;
// If `completion.command.command` is truthy, then a provider has matched with the query
const anyMatches = completions.some((completion) => !!completion.command.command);
hide = !anyMatches;
if (anyMatches) {
hide = false;
if (this.props.onSelectionChange) {
this.props.onSelectionChange(selectionOffset - 1);
}
}
this.setState({
completions,
@ -182,25 +186,25 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
}
hasSelection(): boolean {
public hasSelection(): boolean {
return this.countCompletions() > 0 && this.state.selectionOffset !== 0;
}
countCompletions(): number {
public countCompletions(): number {
return this.state.completionList.length;
}
// called from MessageComposerInput
moveSelection(delta: number) {
public moveSelection(delta: number): void {
const completionCount = this.countCompletions();
if (completionCount === 0) return; // there are no items to move the selection through
// Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected
const index = (this.state.selectionOffset + delta + completionCount + 1) % (completionCount + 1);
this.setSelection(index);
const index = (this.state.selectionOffset + delta + completionCount - 1) % completionCount;
this.setSelection(1 + index);
}
onEscape(e: KeyboardEvent): boolean {
public onEscape(e: KeyboardEvent): boolean {
const completionCount = this.countCompletions();
if (completionCount === 0) {
// autocomplete is already empty, so don't preventDefault
@ -213,16 +217,16 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
this.hide();
}
hide = () => {
private hide = (): void => {
this.setState({
hide: true,
selectionOffset: 0,
selectionOffset: 1,
completions: [],
completionList: [],
});
};
forceComplete() {
public forceComplete(): Promise<number> {
return new Promise((resolve) => {
this.setState({
forceComplete: true,
@ -235,8 +239,13 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
}
onCompletionClicked = (selectionOffset: number): boolean => {
if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) {
public onConfirmCompletion = (): void => {
this.onCompletionClicked(this.state.selectionOffset);
};
private onCompletionClicked = (selectionOffset: number): boolean => {
const count = this.countCompletions();
if (count === 0 || selectionOffset < 1 || selectionOffset > count) {
return false;
}
@ -246,10 +255,10 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
return true;
};
setSelection(selectionOffset: number) {
private setSelection(selectionOffset: number): void {
this.setState({ selectionOffset, hide: false });
if (this.props.onSelectionChange) {
this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1);
this.props.onSelectionChange(selectionOffset - 1);
}
}
@ -292,7 +301,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection">
<div key={i} className="mx_Autocomplete_ProviderSection" role="presentation">
<div className="mx_Autocomplete_provider_name">{ completionResult.provider.getName() }</div>
{ completionResult.provider.renderCompletions(completions) }
</div>
@ -300,7 +309,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
}).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={this.containerRef}>
<div id="mx_Autocomplete" className="mx_Autocomplete" ref={this.containerRef} role="listbox">
{ renderedCompletions }
</div>
) : null;

View file

@ -1,6 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -32,7 +31,7 @@ import {
} from '../../../editor/operations';
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
import { getAutoCompleteCreator } from '../../../editor/parts';
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
import { renderModel } from '../../../editor/render';
import TypingStore from "../../../stores/TypingStore";
@ -55,6 +54,14 @@ const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.sourc
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"];
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
["(", ")"],
["[", "]"],
["{", "}"],
["<", ">"],
]);
function ctrlShortcutLabel(key: string): string {
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
}
@ -99,6 +106,7 @@ interface IState {
showVisualBell?: boolean;
autoComplete?: AutocompleteWrapperModel;
completionIndex?: number;
surroundWith: boolean;
}
@replaceableComponent("views.rooms.BasicMessageEditor")
@ -117,12 +125,15 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private readonly emoticonSettingHandle: string;
private readonly shouldShowPillAvatarSettingHandle: string;
private readonly surroundWithHandle: string;
private readonly historyManager = new HistoryManager();
constructor(props) {
super(props);
this.state = {
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
showVisualBell: false,
};
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
@ -130,6 +141,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.configureEmoticonAutoReplace();
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
this.configureShouldShowPillAvatar);
this.surroundWithHandle = SettingsStore.watchSetting("MessageComposerInput.surroundWith", null,
this.surroundWithSettingChanged);
}
public componentDidUpdate(prevProps: IProps) {
@ -157,7 +170,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index];
n -= 1;
return n >= 0 && (part.type === "plain" || part.type === "pill-candidate");
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate);
});
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
if (emoticonMatch) {
@ -203,7 +216,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
if (isEmpty) {
this.formatBarRef.current.hide();
}
this.setState({ autoComplete: this.props.model.autoComplete });
this.setState({
autoComplete: this.props.model.autoComplete,
// if a change is happening then clear the showVisualBell
showVisualBell: diff ? false : this.state.showVisualBell,
});
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
let isTyping = !this.props.model.isEmpty;
@ -422,6 +439,66 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private onKeyDown = (event: React.KeyboardEvent): void => {
const model = this.props.model;
let handled = false;
if (this.state.surroundWith && document.getSelection().type !== "Caret") {
// This surrounds the selected text with a character. This is
// intentionally left out of the keybinding manager as the keybinds
// here shouldn't be changeable
const selectionRange = getRangeForSelection(
this.editorRef.current,
this.props.model,
document.getSelection(),
);
// trim the range as we want it to exclude leading/trailing spaces
selectionRange.trim();
if ([...SURROUND_WITH_DOUBLE_CHARACTERS.keys(), ...SURROUND_WITH_CHARACTERS].includes(event.key)) {
this.historyManager.ensureLastChangesPushed(this.props.model);
this.modifiedFlag = true;
toggleInlineFormat(selectionRange, event.key, SURROUND_WITH_DOUBLE_CHARACTERS.get(event.key));
handled = true;
}
}
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
if (model.autoComplete?.hasCompletions()) {
const autoComplete = model.autoComplete;
switch (autocompleteAction) {
case AutocompleteAction.ForceComplete:
case AutocompleteAction.Complete:
autoComplete.confirmCompletion();
handled = true;
break;
case AutocompleteAction.PrevSelection:
autoComplete.selectPreviousSelection();
handled = true;
break;
case AutocompleteAction.NextSelection:
autoComplete.selectNextSelection();
handled = true;
break;
case AutocompleteAction.Cancel:
autoComplete.onEscape(event);
handled = true;
break;
default:
return; // don't preventDefault on anything else
}
} else if (autocompleteAction === AutocompleteAction.ForceComplete && !this.state.showVisualBell) {
// there is no current autocomplete window, try to open it
this.tabCompleteName();
handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
this.formatBarRef.current.hide();
}
if (handled) {
event.preventDefault();
event.stopPropagation();
return;
}
const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case MessageComposerAction.FormatBold:
@ -473,42 +550,6 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
handled = true;
break;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
return;
}
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
if (model.autoComplete && model.autoComplete.hasCompletions()) {
const autoComplete = model.autoComplete;
switch (autocompleteAction) {
case AutocompleteAction.CompleteOrPrevSelection:
case AutocompleteAction.PrevSelection:
autoComplete.selectPreviousSelection();
handled = true;
break;
case AutocompleteAction.CompleteOrNextSelection:
case AutocompleteAction.NextSelection:
autoComplete.selectNextSelection();
handled = true;
break;
case AutocompleteAction.Cancel:
autoComplete.onEscape(event);
handled = true;
break;
default:
return; // don't preventDefault on anything else
}
} else if (autocompleteAction === AutocompleteAction.CompleteOrPrevSelection
|| autocompleteAction === AutocompleteAction.CompleteOrNextSelection) {
// there is no current autocomplete window, try to open it
this.tabCompleteName();
handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
this.formatBarRef.current.hide();
}
if (handled) {
event.preventDefault();
event.stopPropagation();
@ -524,9 +565,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && part.text[offset] !== "+" && (
part.type === "plain" ||
part.type === "pill-candidate" ||
part.type === "command"
part.type === Type.Plain ||
part.type === Type.PillCandidate ||
part.type === Type.Command
);
});
const { partCreator } = model;
@ -543,6 +584,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ showVisualBell: true });
model.autoComplete.close();
}
} else {
this.setState({ showVisualBell: true });
}
} catch (err) {
console.error(err);
@ -558,9 +601,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.props.model.autoComplete.onComponentConfirm(completion);
};
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number): void => {
private onAutoCompleteSelectionChange = (completionIndex: number): void => {
this.modifiedFlag = true;
this.props.model.autoComplete.onComponentSelectionChange(completion);
this.setState({ completionIndex });
};
@ -574,6 +616,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ showPillAvatar });
};
private surroundWithSettingChanged = () => {
const surroundWith = SettingsStore.getValue("MessageComposerInput.surroundWith");
this.setState({ surroundWith });
};
componentWillUnmount() {
document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true);
@ -581,6 +628,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
SettingsStore.unwatchSetting(this.emoticonSettingHandle);
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
SettingsStore.unwatchSetting(this.surroundWithHandle);
}
componentDidMount() {
@ -678,13 +726,18 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
};
const { completionIndex } = this.state;
const hasAutocomplete = Boolean(this.state.autoComplete);
let activeDescendant;
if (hasAutocomplete && completionIndex >= 0) {
activeDescendant = generateCompletionDomId(completionIndex);
}
return (<div className={wrapperClasses}>
{ autoComplete }
<MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} />
<div
className={classes}
contentEditable="true"
contentEditable={this.props.disabled ? null : true}
tabIndex={0}
onBlur={this.onBlur}
onFocus={this.onFocus}
@ -696,10 +749,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
aria-label={this.props.label}
role="textbox"
aria-multiline="true"
aria-autocomplete="both"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={Boolean(this.state.autoComplete)}
aria-activedescendant={completionIndex >= 0 ? generateCompletionDomId(completionIndex) : undefined}
aria-expanded={hasAutocomplete}
aria-owns="mx_Autocomplete"
aria-activedescendant={activeDescendant}
dir="auto"
aria-disabled={this.props.disabled}
/>

View file

@ -25,7 +25,7 @@ import { getCaretOffsetAndText } from '../../../editor/dom';
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -43,11 +43,6 @@ import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
import AccessibleButton from '../elements/AccessibleButton';
function eventIsReply(mxEvent: MatrixEvent): boolean {
const relatesTo = mxEvent.getContent()["m.relates_to"];
return !!(relatesTo && relatesTo["m.in_reply_to"]);
}
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
if (!html) {
@ -72,7 +67,7 @@ function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IConte
if (isEmote) {
model = stripEmoteCommand(model);
}
const isReply = eventIsReply(editedEvent);
const isReply = !!editedEvent.replyEventId;
let plainPrefix = "";
let htmlPrefix = "";
@ -242,12 +237,12 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}
@ -268,7 +263,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
if (part.type === Type.UserPill) {
return text + part.resourceId;
}
return text + part.text;

View file

@ -21,6 +21,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Thread } from 'matrix-js-sdk/src/models/thread';
import ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler';
@ -55,6 +56,8 @@ import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import SettingsStore from "../../../settings/SettingsStore";
const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
@ -240,6 +243,7 @@ interface IProps {
// opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations. Should be an empty object when the room
// first loads
// TODO: Proper typing for RR info
readReceiptMap?: any;
// A function which is used to check if the parent panel is being
@ -299,6 +303,9 @@ interface IProps {
// whether or not to display the sender
hideSender?: boolean;
// whether or not to display thread info
showThreadInfo?: boolean;
}
interface IState {
@ -315,6 +322,8 @@ interface IState {
reactions: Relations;
hover: boolean;
thread?: Thread;
}
@replaceableComponent("views.rooms.EventTile")
@ -351,6 +360,8 @@ export default class EventTile extends React.Component<IProps, IState> {
reactions: this.getReactions(),
hover: false,
thread: this.props.mxEvent?.getThread(),
};
// don't do RR animations until we are mounted
@ -451,8 +462,20 @@ export default class EventTile extends React.Component<IProps, IState> {
client.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true;
}
if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.once("Thread.ready", this.updateThread);
this.props.mxEvent.on("Thread.update", this.updateThread);
}
}
private updateThread = (thread) => {
this.setState({
thread,
});
this.forceUpdate();
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(nextProps) {
@ -463,7 +486,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
shouldComponentUpdate(nextProps, nextState) {
shouldComponentUpdate(nextProps, nextState, nextContext) {
if (objectHasDiff(this.state, nextState)) {
return true;
}
@ -491,6 +514,43 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
private renderThreadInfo(): React.ReactNode {
if (!SettingsStore.getValue("feature_thread")) {
return null;
}
const thread = this.state.thread;
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (!thread || this.props.showThreadInfo === false) {
return null;
}
const avatars = Array.from(thread.participants).map((mxId: string) => {
const member = room.getMember(mxId);
return <MemberAvatar key={member.userId} member={member} width={14} height={14} />;
});
return (
<div
className="mx_ThreadInfo"
onClick={() => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadView,
refireParams: {
event: this.props.mxEvent,
},
});
}}
>
<span className="mx_EventListSummary_avatars">
{ avatars }
</span>
{ thread.length - 1 } { thread.length === 2 ? 'reply' : 'replies' }
</div>
);
}
private onRoomReceipt = (ev, room) => {
// ignore events for other rooms
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
@ -711,9 +771,12 @@ export default class EventTile extends React.Component<IProps, IState> {
// add to the start so the most recent is on the end (ie. ends up rightmost)
avatars.unshift(
<ReadReceiptMarker key={userId} member={receipt.roomMember}
<ReadReceiptMarker
key={userId}
member={receipt.roomMember}
fallbackUserId={userId}
leftOffset={left} hidden={hidden}
leftOffset={left}
hidden={hidden}
readReceiptInfo={readReceiptInfo}
checkUnmounting={this.props.checkUnmounting}
suppressAnimation={this.suppressReadReceiptAnimation}
@ -853,13 +916,19 @@ export default class EventTile extends React.Component<IProps, IState> {
render() {
const msgtype = this.props.mxEvent.getContent().msgtype;
const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
const eventType = this.props.mxEvent.getType() as EventType;
const {
tileHandler,
isBubbleMessage,
isInfoMessage,
isLeftAlignedBubbleMessage,
} = getEventDisplayInfo(this.props.mxEvent);
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!tileHandler) {
const { mxEvent } = this.props;
console.warn(`Event type not supported: type:${mxEvent.getType()} isState:${mxEvent.isState()}`);
console.warn(`Event type not supported: type:${eventType} isState:${mxEvent.isState()}`);
return <div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
<div className="mx_EventTile_line">
{ _t('This event could not be displayed') }
@ -875,6 +944,7 @@ export default class EventTile extends React.Component<IProps, IState> {
const isEditing = !!this.props.editState;
const classes = classNames({
mx_EventTile_bubbleContainer: isBubbleMessage,
mx_EventTile_leftAlignedBubble: isLeftAlignedBubbleMessage,
mx_EventTile: true,
mx_EventTile_isEditing: isEditing,
mx_EventTile_info: isInfoMessage,
@ -883,7 +953,10 @@ export default class EventTile extends React.Component<IProps, IState> {
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_highlight: this.props.tileShape === TileShape.Notif ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
mx_EventTile_continuation: (
(this.props.tileShape ? '' : this.props.continuation) ||
eventType === EventType.CallInvite
),
mx_EventTile_last: this.props.last,
mx_EventTile_lastInSection: this.props.lastInSection,
mx_EventTile_contextual: this.props.contextual,
@ -893,6 +966,7 @@ export default class EventTile extends React.Component<IProps, IState> {
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN,
mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_noSender: this.props.hideSender,
});
// If the tile is in the Sending state, don't speak the message.
@ -928,8 +1002,11 @@ export default class EventTile extends React.Component<IProps, IState> {
} else if (this.props.layout == Layout.IRC) {
avatarSize = 14;
needsSenderProfile = true;
} else if (this.props.continuation && this.props.tileShape !== TileShape.FileGrid) {
// no avatar or sender profile for continuation messages
} else if (
(this.props.continuation && this.props.tileShape !== TileShape.FileGrid) ||
eventType === EventType.CallInvite
) {
// no avatar or sender profile for continuation messages and call tiles
avatarSize = 0;
needsSenderProfile = false;
} else {
@ -949,8 +1026,10 @@ export default class EventTile extends React.Component<IProps, IState> {
}
avatar = (
<div className="mx_EventTile_avatar">
<MemberAvatar member={member}
width={avatarSize} height={avatarSize}
<MemberAvatar
member={member}
width={avatarSize}
height={avatarSize}
viewUserOnClick={true}
/>
</div>
@ -1160,8 +1239,10 @@ export default class EventTile extends React.Component<IProps, IState> {
/>
{ keyRequestInfo }
{ actionBar }
{ this.props.layout === Layout.IRC && (reactionsRow) }
{ this.renderThreadInfo() }
</div>
{ reactionsRow }
{ this.props.layout !== Layout.IRC && (reactionsRow) }
{ msgOption }
</>)
);

View file

@ -14,11 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames';
export default (props) => {
interface IProps {
numUnreadMessages: number;
highlight: boolean;
onScrollToBottomClick: (e: React.MouseEvent) => void;
}
const JumpToBottomButton: React.FC<IProps> = (props) => {
const className = classNames({
'mx_JumpToBottomButton': true,
'mx_JumpToBottomButton_highlight': props.highlight,
@ -28,10 +35,13 @@ export default (props) => {
badge = (<div className="mx_JumpToBottomButton_badge">{ props.numUnreadMessages }</div>);
}
return (<div className={className}>
<AccessibleButton className="mx_JumpToBottomButton_scrollDown"
<AccessibleButton
className="mx_JumpToBottomButton_scrollDown"
title={_t("Scroll to most recent messages")}
onClick={props.onScrollToBottomClick}>
</AccessibleButton>
onClick={props.onScrollToBottomClick}
/>
{ badge }
</div>);
};
export default JumpToBottomButton;

View file

@ -45,6 +45,8 @@ import BaseAvatar from '../avatars/BaseAvatar';
import { throttle } from 'lodash';
import SpaceStore from "../../../stores/SpaceStore";
const getSearchQueryLSKey = (roomId: string) => `mx_MemberList_searchQuarry_${roomId}`;
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
const SHOW_MORE_INCREMENT = 100;
@ -171,20 +173,27 @@ export default class MemberList extends React.Component<IProps, IState> {
}
private getMembersState(members: Array<RoomMember>): IState {
let searchQuery;
try {
searchQuery = window.localStorage.getItem(getSearchQueryLSKey(this.props.roomId));
} catch (error) {
console.warn("Failed to get last the MemberList search query", error);
}
// set the state after determining showPresence to make sure it's
// taken into account while rendering
return {
loading: false,
members: members,
filteredJoinedMembers: this.filterMembers(members, 'join'),
filteredInvitedMembers: this.filterMembers(members, 'invite'),
filteredJoinedMembers: this.filterMembers(members, 'join', searchQuery),
filteredInvitedMembers: this.filterMembers(members, 'invite', searchQuery),
canInvite: this.canInvite,
// ideally we'd size this to the page height, but
// in practice I find that a little constraining
truncateAtJoined: INITIAL_LOAD_NUM_MEMBERS,
truncateAtInvited: INITIAL_LOAD_NUM_INVITED,
searchQuery: "",
searchQuery: searchQuery ?? "",
};
}
@ -306,10 +315,16 @@ export default class MemberList extends React.Component<IProps, IState> {
// For now we'll pretend this is any entity. It should probably be a separate tile.
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={onClick} />
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
}
name={text}
presenceState="online"
suppressOnHover={true}
onClick={onClick}
/>
);
};
@ -408,6 +423,12 @@ export default class MemberList extends React.Component<IProps, IState> {
};
private onSearchQueryChanged = (searchQuery: string): void => {
try {
window.localStorage.setItem(getSearchQueryLSKey(this.props.roomId), searchQuery);
} catch (error) {
console.warn("Failed to set the last MemberList search query", error);
}
this.setState({
searchQuery,
filteredJoinedMembers: this.filterMembers(this.state.members, 'join', searchQuery),
@ -465,8 +486,12 @@ export default class MemberList extends React.Component<IProps, IState> {
return <MemberTile key={m.userId} member={m} ref={m.userId} showPresence={this.showPresence} />;
} else {
// Is a 3pid invite
return <EntityTile key={m.getStateKey()} name={m.getContent().display_name} suppressOnHover={true}
onClick={() => this.onPending3pidInviteClick(m)} />;
return <EntityTile
key={m.getStateKey()}
name={m.getContent().display_name}
suppressOnHover={true}
onClick={() => this.onPending3pidInviteClick(m)}
/>;
}
});
}
@ -544,7 +569,9 @@ export default class MemberList extends React.Component<IProps, IState> {
<SearchBox
className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={_t('Filter room members')}
onSearch={this.onSearchQueryChanged} />
onSearch={this.onSearchQueryChanged}
initialValue={this.state.searchQuery}
/>
);
let previousPhase = RightPanelPhases.RoomSummary;

View file

@ -13,7 +13,7 @@ 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 React, { createRef } from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
@ -27,7 +27,12 @@ import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalin
import ContentMessages from '../../../ContentMessages';
import E2EIcon from './E2EIcon';
import SettingsStore from "../../../settings/SettingsStore";
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import {
aboveLeftOf,
ContextMenu,
useContextMenu,
MenuItem,
} from "../../structures/ContextMenu";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview";
import { UIFeature } from "../../../settings/UIFeature";
@ -35,7 +40,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
import { RecordingState } from "../../../voice/VoiceRecording";
import { RecordingState } from "../../../audio/VoiceRecording";
import Tooltip, { Alignment } from "../elements/Tooltip";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from '../../../utils/ShieldUtils';
@ -45,6 +50,10 @@ import { Action } from "../../../dispatcher/actions";
import EditorModel from "../../../editor/model";
import EmojiPicker from '../emojipicker/EmojiPicker';
import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar";
import UIStore, { UI_EVENTS } from '../../../stores/UIStore';
let instanceCount = 0;
const NARROW_MODE_BREAKPOINT = 500;
interface IComposerAvatarProps {
me: object;
@ -58,6 +67,7 @@ function ComposerAvatar(props: IComposerAvatarProps) {
interface ISendButtonProps {
onClick: () => void;
title?: string; // defaults to something generic
}
function SendButton(props: ISendButtonProps) {
@ -65,18 +75,24 @@ function SendButton(props: ISendButtonProps) {
<AccessibleTooltipButton
className="mx_MessageComposer_sendMessage"
onClick={props.onClick}
title={_t('Send message')}
title={props.title ?? _t('Send message')}
/>
);
}
const EmojiButton = ({ addEmoji }) => {
interface IEmojiButtonProps {
addEmoji: (unicode: string) => boolean;
menuPosition: any; // TODO: Types
narrowMode: boolean;
}
const EmojiButton: React.FC<IEmojiButtonProps> = ({ addEmoji, menuPosition, narrowMode }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect());
contextMenu = <ContextMenu {...position} onFinished={closeMenu} managed={false}>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>;
}
@ -92,15 +108,12 @@ const EmojiButton = ({ addEmoji }) => {
// TODO: replace ContextMenuTooltipButton with a unified representation of
// the header buttons and the right panel buttons
return <React.Fragment>
<ContextMenuTooltipButton
<AccessibleTooltipButton
className={className}
onClick={openMenu}
isExpanded={menuDisplayed}
title={_t('Emoji picker')}
inputRef={button}
>
</ContextMenuTooltipButton>
title={!narrowMode && _t('Emoji picker')}
label={narrowMode && _t("Add emoji")}
/>
{ contextMenu }
</React.Fragment>;
@ -184,7 +197,10 @@ interface IProps {
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
replyInThread?: boolean;
showReplyPreview?: boolean;
e2eStatus?: E2EStatus;
compact?: boolean;
}
interface IState {
@ -194,6 +210,9 @@ interface IState {
haveRecording: boolean;
recordingTimeLeftSeconds?: number;
me?: RoomMember;
narrowMode?: boolean;
isMenuOpen: boolean;
showStickers: boolean;
}
@replaceableComponent("views.rooms.MessageComposer")
@ -201,6 +220,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef: string;
private messageComposerInput: SendMessageComposer;
private voiceRecordingButton: VoiceRecordComposerTile;
private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number;
static defaultProps = {
replyInThread: false,
showReplyPreview: true,
compact: false,
};
constructor(props) {
super(props);
@ -212,15 +239,32 @@ export default class MessageComposer extends React.Component<IProps, IState> {
isComposerEmpty: true,
haveRecording: false,
recordingTimeLeftSeconds: null, // when set to a number, shows a toast
isMenuOpen: false,
showStickers: false,
};
this.instanceId = instanceCount++;
}
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
this.waitForOwnMember();
UIStore.instance.trackElementDimensions(`MessageComposer${this.instanceId}`, this.ref.current);
UIStore.instance.on(`MessageComposer${this.instanceId}`, this.onResize);
}
private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry) => {
if (type === UI_EVENTS.Resize) {
const narrowMode = entry.contentRect.width <= NARROW_MODE_BREAKPOINT;
this.setState({
narrowMode,
isMenuOpen: !narrowMode ? false : this.state.isMenuOpen,
showStickers: false,
});
}
};
private onAction = (payload: ActionPayload) => {
if (payload.action === 'reply_to_event') {
// add a timeout for the reply preview to be rendered, so
@ -255,6 +299,8 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}
VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate);
dis.unregister(this.dispatcherRef);
UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`);
UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize);
}
private onRoomStateEvents = (ev, state) => {
@ -304,7 +350,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private renderPlaceholderText = () => {
if (this.props.replyToEvent) {
if (this.props.e2eStatus) {
if (this.props.replyInThread && this.props.e2eStatus) {
return _t('Reply to encrypted thread…');
} else if (this.props.replyInThread) {
return _t('Reply to thread…');
} else if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
} else {
return _t('Send a reply…');
@ -318,11 +368,12 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}
};
private addEmoji(emoji: string) {
private addEmoji(emoji: string): boolean {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
text: emoji,
});
return true;
}
private sendMessage = async () => {
@ -344,8 +395,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private onVoiceStoreUpdate = () => {
const recording = VoiceRecordingStore.instance.activeRecording;
this.setState({ haveRecording: !!recording });
if (recording) {
// Delay saying we have a recording until it is started, as we might not yet have A/V permissions
recording.on(RecordingState.Started, () => {
this.setState({ haveRecording: !!VoiceRecordingStore.instance.activeRecording });
});
// We show a little heads up that the recording is about to automatically end soon. The 3s
// display time is completely arbitrary. Note that we don't need to deregister the listener
// because the recording instance will clean that up for us.
@ -353,17 +407,116 @@ export default class MessageComposer extends React.Component<IProps, IState> {
this.setState({ recordingTimeLeftSeconds: secondsLeft });
setTimeout(() => this.setState({ recordingTimeLeftSeconds: null }), 3000);
});
} else {
this.setState({ haveRecording: false });
}
};
private shouldShowStickerPicker = (): boolean => {
return SettingsStore.getValue(UIFeature.Widgets)
&& SettingsStore.getValue("MessageComposerInput.showStickersButton")
&& !this.state.haveRecording;
};
private showStickers = (showStickers: boolean) => {
this.setState({ showStickers });
};
private toggleButtonMenu = (): void => {
this.setState({
isMenuOpen: !this.state.isMenuOpen,
});
};
private renderButtons(menuPosition): JSX.Element | JSX.Element[] {
const buttons: JSX.Element[] = [];
if (!this.state.haveRecording) {
buttons.push(
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
);
buttons.push(
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} menuPosition={menuPosition} narrowMode={this.state.narrowMode} />,
);
}
if (this.shouldShowStickerPicker()) {
let title;
if (!this.state.narrowMode) {
title = this.state.showStickers ? _t("Hide Stickers") : _t("Show Stickers");
}
buttons.push(
<AccessibleTooltipButton
id='stickersButton'
key="controls_stickers"
className="mx_MessageComposer_button mx_MessageComposer_stickers"
onClick={() => this.showStickers(!this.state.showStickers)}
title={title}
label={this.state.narrowMode && _t("Send a sticker")}
/>,
);
}
if (!this.state.haveRecording && !this.state.narrowMode) {
buttons.push(
<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_voiceMessage"
onClick={() => this.voiceRecordingButton?.onRecordStartEndClick()}
title={_t("Send voice message")}
/>,
);
}
if (!this.state.narrowMode) {
return buttons;
} else {
const classnames = classNames({
mx_MessageComposer_button: true,
mx_MessageComposer_buttonMenu: true,
mx_MessageComposer_closeButtonMenu: this.state.isMenuOpen,
});
return <>
{ buttons[0] }
<AccessibleTooltipButton
className={classnames}
onClick={this.toggleButtonMenu}
title={_t("More options")}
tooltip={false}
/>
{ this.state.isMenuOpen && (
<ContextMenu
onFinished={this.toggleButtonMenu}
{...menuPosition}
menuPaddingRight={10}
menuPaddingTop={5}
menuPaddingBottom={5}
menuWidth={150}
wrapperClassName="mx_MessageComposer_Menu"
>
{ buttons.slice(1).map((button, index) => (
<MenuItem className="mx_CallContextMenu_item" key={index} onClick={this.toggleButtonMenu}>
{ button }
</MenuItem>
)) }
</ContextMenu>
) }
</>;
}
}
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.state.me && !this.props.compact ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.props.e2eStatus ?
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
null,
];
let menuPosition;
if (this.ref.current) {
const contentRect = this.ref.current.getBoundingClientRect();
menuPosition = aboveLeftOf(contentRect);
}
if (!this.state.tombstone && this.state.canSendMessages) {
controls.push(
<SendMessageComposer
@ -372,37 +525,17 @@ export default class MessageComposer extends React.Component<IProps, IState> {
room={this.props.room}
placeholder={this.renderPlaceholderText()}
permalinkCreator={this.props.permalinkCreator}
replyInThread={this.props.replyInThread}
replyToEvent={this.props.replyToEvent}
onChange={this.onChange}
disabled={this.state.haveRecording}
/>,
);
if (!this.state.haveRecording) {
controls.push(
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} />,
);
}
if (SettingsStore.getValue(UIFeature.Widgets) &&
SettingsStore.getValue("MessageComposerInput.showStickersButton") &&
!this.state.haveRecording) {
controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />);
}
if (SettingsStore.getValue("feature_voice_messages")) {
controls.push(<VoiceRecordComposerTile
key="controls_voice_record"
ref={c => this.voiceRecordingButton = c}
room={this.props.room} />);
}
if (!this.state.isComposerEmpty || this.state.haveRecording) {
controls.push(
<SendButton key="controls_send" onClick={this.sendMessage} />,
);
}
controls.push(<VoiceRecordComposerTile
key="controls_voice_record"
ref={c => this.voiceRecordingButton = c}
room={this.props.room} />);
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
@ -439,17 +572,43 @@ export default class MessageComposer extends React.Component<IProps, IState> {
if (secondsLeft) {
recordingTooltip = <Tooltip
label={_t("%(seconds)ss left", { seconds: secondsLeft })}
alignment={Alignment.Top} yOffset={-50}
alignment={Alignment.Top}
yOffset={-50}
/>;
}
controls.push(
<Stickerpicker
room={this.props.room}
showStickers={this.state.showStickers}
setShowStickers={this.showStickers}
menuPosition={menuPosition} />,
);
const showSendButton = !this.state.isComposerEmpty || this.state.haveRecording;
const classes = classNames({
"mx_MessageComposer": true,
"mx_GroupLayout": true,
"mx_MessageComposer--compact": this.props.compact,
});
return (
<div className="mx_MessageComposer mx_GroupLayout">
<div className={classes} ref={this.ref}>
{ recordingTooltip }
<div className="mx_MessageComposer_wrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
{ this.props.showReplyPreview && (
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
) }
<div className="mx_MessageComposer_row">
{ controls }
{ this.renderButtons(menuPosition) }
{ showSendButton && (
<SendButton
key="controls_send"
onClick={this.sendMessage}
title={this.state.haveRecording ? _t("Send voice message") : undefined}
/>
) }
</div>
</div>
</div>

View file

@ -36,6 +36,7 @@ import { showSpaceInvite } from "../../../utils/space";
import { privateShouldBeEncrypted } from "../../../createRoom";
import EventTileBubble from "../messages/EventTileBubble";
import { ROOM_SECURITY_TAB } from "../dialogs/RoomSettingsDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
@ -58,13 +59,18 @@ const NewRoomIntro = () => {
const member = room?.getMember(dmPartner);
const displayName = member?.rawDisplayName || dmPartner;
body = <React.Fragment>
<RoomAvatar room={room} width={AVATAR_SIZE} height={AVATAR_SIZE} onClick={() => {
defaultDispatcher.dispatch<ViewUserPayload>({
action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the receiver wants.
member: member || { userId: dmPartner } as User,
});
}} />
<RoomAvatar
room={room}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
onClick={() => {
defaultDispatcher.dispatch<ViewUserPayload>({
action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the receiver wants.
member: member || { userId: dmPartner } as User,
});
}}
/>
<h2>{ room.name }</h2>
@ -186,11 +192,21 @@ const NewRoomIntro = () => {
});
}
const sub2 = _t(
const subText = _t(
"Your private messages are normally encrypted, but this room isn't. "+
"Usually this is due to an unsupported device or method being used, " +
"like email invites. <a>Enable encryption in settings.</a>", {},
{ a: sub => <a onClick={openRoomSettings} href="#">{ sub }</a> },
"like email invites.",
);
let subButton;
if (room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.get())) {
subButton = (
<a onClick={openRoomSettings} href="#"> { _t("Enable encryption in settings.") }</a>
);
}
const subtitle = (
<span> { subText } { subButton } </span>
);
return <div className="mx_NewRoomIntro">
@ -199,7 +215,7 @@ const NewRoomIntro = () => {
<EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon_warning"
title={_t("End-to-end encryption isn't enabled")}
subtitle={sub2}
subtitle={subtitle}
/>
) }

View file

@ -15,62 +15,75 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import React, { createRef, RefObject } from 'react';
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { _t } from '../../../languageHandler';
import { formatDate } from '../../../DateUtils';
import NodeAnimator from "../../../NodeAnimator";
import * as sdk from "../../../index";
import { toPx } from "../../../utils/units";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import MemberAvatar from '../avatars/MemberAvatar';
interface IProps {
// the RoomMember to show the RR for
member?: RoomMember;
// userId to fallback the avatar to
// if the member hasn't been loaded yet
fallbackUserId: string;
// number of pixels to offset the avatar from the right of its parent;
// typically a negative value.
leftOffset?: number;
// true to hide the avatar (it will still be animated)
hidden?: boolean;
// don't animate this RR into position
suppressAnimation?: boolean;
// an opaque object for storing information about this user's RR in
// this room
// TODO: proper typing for RR info
readReceiptInfo: any;
// A function which is used to check if the parent panel is being
// unmounted, to avoid unnecessary work. Should return true if we
// are being unmounted.
checkUnmounting?: () => boolean;
// callback for clicks on this RR
onClick?: (e: React.MouseEvent) => void;
// Timestamp when the receipt was read
timestamp?: number;
// True to show twelve hour format, false otherwise
showTwelveHour?: boolean;
}
interface IState {
suppressDisplay: boolean;
startStyles?: IReadReceiptMarkerStyle[];
}
interface IReadReceiptMarkerStyle {
top: number;
left: number;
}
@replaceableComponent("views.rooms.ReadReceiptMarker")
export default class ReadReceiptMarker extends React.PureComponent {
static propTypes = {
// the RoomMember to show the RR for
member: PropTypes.object,
// userId to fallback the avatar to
// if the member hasn't been loaded yet
fallbackUserId: PropTypes.string.isRequired,
// number of pixels to offset the avatar from the right of its parent;
// typically a negative value.
leftOffset: PropTypes.number,
// true to hide the avatar (it will still be animated)
hidden: PropTypes.bool,
// don't animate this RR into position
suppressAnimation: PropTypes.bool,
// an opaque object for storing information about this user's RR in
// this room
readReceiptInfo: PropTypes.object,
// A function which is used to check if the parent panel is being
// unmounted, to avoid unnecessary work. Should return true if we
// are being unmounted.
checkUnmounting: PropTypes.func,
// callback for clicks on this RR
onClick: PropTypes.func,
// Timestamp when the receipt was read
timestamp: PropTypes.number,
// True to show twelve hour format, false otherwise
showTwelveHour: PropTypes.bool,
};
export default class ReadReceiptMarker extends React.PureComponent<IProps, IState> {
private avatar: React.RefObject<HTMLDivElement | HTMLImageElement | HTMLSpanElement> = createRef();
static defaultProps = {
leftOffset: 0,
};
constructor(props) {
constructor(props: IProps) {
super(props);
this._avatar = createRef();
this.state = {
// if we are going to animate the RR, we don't show it on first render,
// and instead just add a placeholder to the DOM; once we've been
@ -80,7 +93,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
};
}
componentWillUnmount() {
public componentWillUnmount(): void {
// before we remove the rr, store its location in the map, so that if
// it reappears, it can be animated from the right place.
const rrInfo = this.props.readReceiptInfo;
@ -95,29 +108,29 @@ export default class ReadReceiptMarker extends React.PureComponent {
return;
}
const avatarNode = this._avatar.current;
const avatarNode = this.avatar.current;
rrInfo.top = avatarNode.offsetTop;
rrInfo.left = avatarNode.offsetLeft;
rrInfo.parent = avatarNode.offsetParent;
}
componentDidMount() {
public componentDidMount(): void {
if (!this.state.suppressDisplay) {
// we've already done our display - nothing more to do.
return;
}
this._animateMarker();
this.animateMarker();
}
componentDidUpdate(prevProps) {
public componentDidUpdate(prevProps: IProps): void {
const differentLeftOffset = prevProps.leftOffset !== this.props.leftOffset;
const visibilityChanged = prevProps.hidden !== this.props.hidden;
if (differentLeftOffset || visibilityChanged) {
this._animateMarker();
this.animateMarker();
}
}
_animateMarker() {
private animateMarker(): void {
// treat new RRs as though they were off the top of the screen
let oldTop = -15;
@ -126,7 +139,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
oldTop = oldInfo.top + oldInfo.parent.getBoundingClientRect().top;
}
const newElement = this._avatar.current;
const newElement = this.avatar.current;
let startTopOffset;
if (!newElement.offsetParent) {
// this seems to happen sometimes for reasons I don't understand
@ -145,7 +158,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
if (oldInfo && oldInfo.left) {
// start at the old height and in the old h pos
startStyles.push({ top: startTopOffset+"px",
left: toPx(oldInfo.left) });
left: toPx(oldInfo.left) });
}
startStyles.push({ top: startTopOffset+'px', left: '0' });
@ -156,10 +169,9 @@ export default class ReadReceiptMarker extends React.PureComponent {
});
}
render() {
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
public render(): JSX.Element {
if (this.state.suppressDisplay) {
return <div ref={this._avatar} />;
return <div ref={this.avatar as RefObject<HTMLDivElement>} />;
}
const style = {
@ -174,14 +186,14 @@ export default class ReadReceiptMarker extends React.PureComponent {
title = _t(
"Seen by %(userName)s at %(dateTime)s",
{ userName: this.props.fallbackUserId,
dateTime: dateString },
dateTime: dateString },
);
} else {
title = _t(
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s",
{ displayName: this.props.member.rawDisplayName,
userName: this.props.fallbackUserId,
dateTime: dateString },
userName: this.props.fallbackUserId,
dateTime: dateString },
);
}
}
@ -192,11 +204,13 @@ export default class ReadReceiptMarker extends React.PureComponent {
member={this.props.member}
fallbackUserId={this.props.fallbackUserId}
aria-hidden="true"
width={14} height={14} resizeMethod="crop"
width={14}
height={14}
resizeMethod="crop"
style={style}
title={title}
onClick={this.props.onClick}
inputRef={this._avatar}
inputRef={this.avatar as RefObject<HTMLImageElement>}
/>
</NodeAnimator>
);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { createRef } from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
@ -25,8 +25,9 @@ import MImageReplyBody from "../messages/MImageReplyBody";
import * as sdk from '../../../index';
import { EventType, MsgType } from 'matrix-js-sdk/src/@types/event';
import { replaceableComponent } from '../../../utils/replaceableComponent';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { getEventDisplayInfo, isVoiceMessage } from '../../../utils/EventUtils';
import MFileBody from "../messages/MFileBody";
import MVoiceMessageBody from "../messages/MVoiceMessageBody";
interface IProps {
mxEvent: MatrixEvent;
@ -38,6 +39,8 @@ interface IProps {
@replaceableComponent("views.rooms.ReplyTile")
export default class ReplyTile extends React.PureComponent<IProps> {
private anchorElement = createRef<HTMLAnchorElement>();
static defaultProps = {
onHeightChanged: () => {},
};
@ -67,15 +70,25 @@ export default class ReplyTile extends React.PureComponent<IProps> {
};
private onClick = (e: React.MouseEvent): void => {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Riot when clicked.
e.preventDefault();
dis.dispatch({
action: 'view_room',
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
});
const clickTarget = e.target as HTMLElement;
// Following a link within a reply should not dispatch the `view_room` action
// so that the browser can direct the user to the correct location
// The exception being the link wrapping the reply
if (
clickTarget.tagName.toLowerCase() !== "a" ||
clickTarget.closest("a") === null ||
clickTarget === this.anchorElement.current
) {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Riot when clicked.
e.preventDefault();
dis.dispatch({
action: 'view_room',
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
});
}
};
render() {
@ -83,7 +96,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
const msgType = mxEvent.getContent().msgtype;
const evType = mxEvent.getType() as EventType;
const { tileHandler, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
const { tileHandler, isInfoMessage } = getEventDisplayInfo(mxEvent);
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!tileHandler) {
@ -97,14 +110,14 @@ export default class ReplyTile extends React.PureComponent<IProps> {
const EventTileType = sdk.getComponent(tileHandler);
const classes = classNames("mx_ReplyTile", {
mx_ReplyTile_info: isInfoMessage && !this.props.mxEvent.isRedacted(),
mx_ReplyTile_info: isInfoMessage && !mxEvent.isRedacted(),
mx_ReplyTile_audio: msgType === MsgType.Audio,
mx_ReplyTile_video: msgType === MsgType.Video,
});
let permalink = "#";
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
permalink = this.props.permalinkCreator.forEvent(mxEvent.getId());
}
let sender;
@ -117,7 +130,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
if (needsSenderProfile) {
sender = <SenderProfile
mxEvent={this.props.mxEvent}
mxEvent={mxEvent}
enableFlair={false}
/>;
}
@ -125,7 +138,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
const msgtypeOverrides = {
[MsgType.Image]: MImageReplyBody,
// Override audio and video body with file body. We also hide the download/decrypt button using CSS
[MsgType.Audio]: MFileBody,
[MsgType.Audio]: isVoiceMessage(mxEvent) ? MVoiceMessageBody : MFileBody,
[MsgType.Video]: MFileBody,
};
const evOverrides = {
@ -135,18 +148,18 @@ export default class ReplyTile extends React.PureComponent<IProps> {
return (
<div className={classes}>
<a href={permalink} onClick={this.onClick}>
<a href={permalink} onClick={this.onClick} ref={this.anchorElement}>
{ sender }
<EventTileType
ref="tile"
mxEvent={this.props.mxEvent}
mxEvent={mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
showUrlPreview={false}
overrideBodyTypes={msgtypeOverrides}
overrideEventTypes={evOverrides}
replacingEventId={this.props.mxEvent.replacingEventId()}
replacingEventId={mxEvent.replacingEventId()}
maxImageHeight={96} />
</a>
</div>

View file

@ -105,7 +105,9 @@ export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState>
// NOTE: The CSSTransition timeout MUST match the timeout in our CSS!
return (
<CSSTransition
appear={true} in={this.state.doAnimation} timeout={640}
appear={true}
in={this.state.doAnimation}
timeout={640}
classNames='mx_RoomBreadcrumbs'
>
<Toolbar className='mx_RoomBreadcrumbs' aria-label={_t("Recently visited rooms")}>

View file

@ -14,41 +14,38 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import React from 'react';
import { _t } from '../../../languageHandler';
import PropTypes from 'prop-types';
import { Room } from 'matrix-js-sdk/src';
import classNames from 'classnames';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { roomShape } from './RoomDetailRow';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import RoomDetailRow from "./RoomDetailRow";
interface IProps {
rooms?: Room[];
className?: string;
}
@replaceableComponent("views.rooms.RoomDetailList")
export default class RoomDetailList extends React.Component {
static propTypes = {
rooms: PropTypes.arrayOf(roomShape),
className: PropTypes.string,
};
getRows() {
export default class RoomDetailList extends React.Component<IProps> {
private getRows(): JSX.Element[] {
if (!this.props.rooms) return [];
const RoomDetailRow = sdk.getComponent('rooms.RoomDetailRow');
return this.props.rooms.map((room, index) => {
return <RoomDetailRow key={index} room={room} onClick={this.onDetailsClick} />;
});
}
onDetailsClick = (ev, room) => {
private onDetailsClick = (ev: React.MouseEvent, room: Room): void => {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
room_alias: room.canonicalAlias || (room.aliases || [])[0],
room_alias: room.getCanonicalAlias() || (room.getAltAliases() || [])[0],
});
};
render() {
public render(): JSX.Element {
const rows = this.getRows();
let rooms;
if (rows.length === 0) {

View file

@ -105,8 +105,12 @@ export default class RoomDetailRow extends React.Component {
return <tr key={room.roomId} onClick={this.onClick} onMouseDown={this.props.onMouseDown}>
<td className="mx_RoomDirectory_roomAvatar">
<BaseAvatar width={24} height={24} resizeMethod='crop'
name={name} idName={name}
<BaseAvatar
width={24}
height={24}
resizeMethod='crop'
name={name}
idName={name}
url={avatarUrl} />
</td>
<td className="mx_RoomDirectory_roomDescription">

View file

@ -29,6 +29,8 @@ import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
import { PlaceCallType } from "../../../CallHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Modal from '../../../Modal';
import InfoDialog from "../dialogs/InfoDialog";
import { throttle } from 'lodash';
import { MatrixEvent, Room, RoomState } from 'matrix-js-sdk/src';
import { E2EStatus } from '../../../utils/ShieldUtils';
@ -87,6 +89,14 @@ export default class RoomHeader extends React.Component<IProps> {
this.forceUpdate();
}, 500, { leading: true, trailing: true });
private displayInfoDialogAboutScreensharing() {
Modal.createDialog(InfoDialog, {
title: _t("Screen sharing is here!"),
description: _t("You can now share your screen by pressing the \"screen share\" " +
"button during a call. You can even do this in audio calls if both sides support it!"),
});
}
public render() {
let searchStatus = null;
@ -185,8 +195,8 @@ export default class RoomHeader extends React.Component<IProps> {
videoCallButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={(ev) => this.props.onCallPlaced(
ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video)}
onClick={(ev: React.MouseEvent<Element>) => ev.shiftKey ?
this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)}
title={_t("Video call")} />;
}

View file

@ -38,7 +38,6 @@ import { StaticNotificationState } from "../../../stores/notifications/StaticNot
import { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import SettingsStore from "../../../settings/SettingsStore";
import CustomRoomTagStore from "../../../stores/CustomRoomTagStore";
import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays";
import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
@ -49,6 +48,7 @@ import SpaceStore, { ISuggestedRoom, SUGGESTED_ROOMS } from "../../../stores/Spa
import { showAddExistingRooms, showCreateNewRoom, showSpaceInvite } from "../../../utils/space";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import RoomAvatar from "../avatars/RoomAvatar";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
@ -140,7 +140,7 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
e.preventDefault();
e.stopPropagation();
onFinished();
showCreateNewRoom(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
showCreateNewRoom(SpaceStore.instance.activeSpace);
}}
disabled={!canAddRooms}
tooltip={canAddRooms ? undefined
@ -153,7 +153,7 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
e.preventDefault();
e.stopPropagation();
onFinished();
showAddExistingRooms(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
showAddExistingRooms(SpaceStore.instance.activeSpace);
}}
disabled={!canAddRooms}
tooltip={canAddRooms ? undefined
@ -320,11 +320,6 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
private updateLists = () => {
const newLists = RoomListStore.instance.orderedLists;
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log("new lists", newLists);
}
const previousListIds = Object.keys(this.state.sublists);
const newListIds = Object.keys(newLists).filter(t => {
if (!isCustomTag(t)) return true; // always include non-custom tags
@ -428,7 +423,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
groupId={g.groupId}
groupName={g.name}
groupAvatarUrl={g.avatarUrl}
width={32} height={32} resizeMethod='crop'
width={32}
height={32}
resizeMethod='crop'
/>
);
const openGroup = () => {
@ -526,20 +523,23 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
} else if (
this.props.activeSpace?.canInvite(userId) || this.props.activeSpace?.getMyMembership() === "join"
) {
const spaceName = this.props.activeSpace.name;
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{ _t("Quick actions") }</div>
{ this.props.activeSpace.canInvite(userId) && <AccessibleButton
{ this.props.activeSpace.canInvite(userId) && <AccessibleTooltipButton
className="mx_RoomList_explorePrompt_spaceInvite"
onClick={this.onSpaceInviteClick}
title={_t("Invite to %(spaceName)s", { spaceName })}
>
{ _t("Invite people") }
</AccessibleButton> }
{ this.props.activeSpace.getMyMembership() === "join" && <AccessibleButton
</AccessibleTooltipButton> }
{ this.props.activeSpace.getMyMembership() === "join" && <AccessibleTooltipButton
className="mx_RoomList_explorePrompt_spaceExplore"
onClick={this.onExplore}
title={_t("Explore %(spaceName)s", { spaceName })}
>
{ _t("Explore rooms") }
</AccessibleButton> }
</AccessibleTooltipButton> }
</div>;
} else if (Object.values(this.state.sublists).some(list => list.length > 0)) {
const unfilteredLists = RoomListStore.instance.unfilteredLists;

View file

@ -28,6 +28,8 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import InviteReason from "../elements/InviteReason";
const MemberEventHtmlReasonField = "io.element.html_reason";
const MessageCase = Object.freeze({
NotLoggedIn: "NotLoggedIn",
Joining: "Joining",
@ -492,9 +494,13 @@ export default class RoomPreviewBar extends React.Component {
}
const myUserId = MatrixClientPeg.get().getUserId();
const reason = this.props.room.currentState.getMember(myUserId).events.member.event.content.reason;
if (reason) {
reasonElement = <InviteReason reason={reason} />;
const memberEventContent = this.props.room.currentState.getMember(myUserId).events.member.getContent();
if (memberEventContent.reason) {
reasonElement = <InviteReason
reason={memberEventContent.reason}
htmlReason={memberEventContent[MemberEventHtmlReasonField]}
/>;
}
primaryActionHandler = this.props.onJoinClick;
@ -536,8 +542,10 @@ export default class RoomPreviewBar extends React.Component {
"If you think you're seeing this message in error, please " +
"<issueLink>submit a bug report</issueLink>.",
{ errcode: this.props.error.errcode },
{ issueLink: label => <a href="https://github.com/vector-im/element-web/issues/new/choose"
target="_blank" rel="noreferrer noopener">{ label }</a> },
{ issueLink: label => <a
href="https://github.com/vector-im/element-web/issues/new/choose"
target="_blank"
rel="noreferrer noopener">{ label }</a> },
),
];
break;

View file

@ -1,5 +1,5 @@
/*
Copyright 2018-2020 New Vector Ltd
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,41 +15,43 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import RoomUpgradeDialog from '../dialogs/RoomUpgradeDialog';
import AccessibleButton from '../elements/AccessibleButton';
interface IProps {
room: Room;
}
interface IState {
upgraded?: boolean;
}
@replaceableComponent("views.rooms.RoomUpgradeWarningBar")
export default class RoomUpgradeWarningBar extends React.PureComponent {
static propTypes = {
room: PropTypes.object.isRequired,
recommendation: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
export default class RoomUpgradeWarningBar extends React.PureComponent<IProps, IState> {
public componentDidMount(): void {
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
this.setState({ upgraded: tombstone && tombstone.getContent().replacement_room });
MatrixClientPeg.get().on("RoomState.events", this._onStateEvents);
MatrixClientPeg.get().on("RoomState.events", this.onStateEvents);
}
componentWillUnmount() {
public componentWillUnmount(): void {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this._onStateEvents);
cli.removeListener("RoomState.events", this.onStateEvents);
}
}
_onStateEvents = (event, state) => {
private onStateEvents = (event: MatrixEvent, state: RoomState): void => {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return;
}
@ -60,14 +62,11 @@ export default class RoomUpgradeWarningBar extends React.PureComponent {
this.setState({ upgraded: tombstone && tombstone.getContent().replacement_room });
};
onUpgradeClick = () => {
const RoomUpgradeDialog = sdk.getComponent('dialogs.RoomUpgradeDialog');
private onUpgradeClick = (): void => {
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, { room: this.props.room });
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
public render(): JSX.Element {
let doUpgradeWarnings = (
<div>
<div className="mx_RoomUpgradeWarningBar_body">

View file

@ -31,7 +31,7 @@ import {
textSerialize,
unescapeMessage,
} from '../../../editor/serialize';
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils';
@ -54,18 +54,20 @@ import { Room } from 'matrix-js-sdk/src/models/room';
import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
function addReplyToMessageContent(
content: IContent,
repliedToEvent: MatrixEvent,
replyToEvent: MatrixEvent,
replyInThread: boolean,
permalinkCreator: RoomPermalinkCreator,
): void {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
const replyContent = ReplyThread.makeReplyMixIn(replyToEvent, replyInThread);
Object.assign(content, replyContent);
// Part of Replies fallback support - prepend the text we're sending
// with the text we're replying to
const nestedReply = ReplyThread.getNestedReplyText(repliedToEvent, permalinkCreator);
const nestedReply = ReplyThread.getNestedReplyText(replyToEvent, permalinkCreator);
if (nestedReply) {
if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body;
@ -77,8 +79,9 @@ function addReplyToMessageContent(
// exported for tests
export function createMessageContent(
model: EditorModel,
permalinkCreator: RoomPermalinkCreator,
replyToEvent: MatrixEvent,
replyInThread: boolean,
permalinkCreator: RoomPermalinkCreator,
): IContent {
const isEmote = containsEmote(model);
if (isEmote) {
@ -101,7 +104,7 @@ export function createMessageContent(
}
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, permalinkCreator);
addReplyToMessageContent(content, replyToEvent, replyInThread, permalinkCreator);
}
return content;
@ -129,6 +132,7 @@ interface IProps {
room: Room;
placeholder?: string;
permalinkCreator: RoomPermalinkCreator;
replyInThread?: boolean;
replyToEvent?: MatrixEvent;
disabled?: boolean;
onChange?(model: EditorModel): void;
@ -240,14 +244,14 @@ export default class SendMessageComposer extends React.Component<IProps> {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
// be extra resilient when somehow the AutocompleteWrapperModel or
// CommandPartCreator fails to insert a command part, so we don't send
// a command as a message
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}
@ -357,7 +361,12 @@ export default class SendMessageComposer extends React.Component<IProps> {
if (cmd.category === CommandCategories.messages) {
content = await this.runSlashCommand(cmd, args);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
addReplyToMessageContent(
content,
replyToEvent,
this.props.replyInThread,
this.props.permalinkCreator,
);
}
} else {
this.runSlashCommand(cmd, args);
@ -400,11 +409,20 @@ export default class SendMessageComposer extends React.Component<IProps> {
const startTime = CountlyAnalytics.getTimestamp();
const { roomId } = this.props.room;
if (!content) {
content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent);
content = createMessageContent(
this.model,
replyToEvent,
this.props.replyInThread,
this.props.permalinkCreator,
);
}
// don't bother sending an empty message
if (!content.body.trim()) return;
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}
const prom = this.context.sendMessage(roomId, content);
if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue
@ -420,6 +438,11 @@ export default class SendMessageComposer extends React.Component<IProps> {
dis.dispatch({ action: `effects.${effect.command}` });
}
});
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
prom.then(resp => {
sendRoundTripMetric(this.context, roomId, resp.event_id);
});
}
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2016-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,33 +15,33 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
title?: string;
// `src` to an image. Optional.
icon?: string;
}
/*
* A stripped-down room header used for things like the user settings
* and room directory.
*/
@replaceableComponent("views.rooms.SimpleRoomHeader")
export default class SimpleRoomHeader extends React.Component {
static propTypes = {
title: PropTypes.string,
// `src` to an image. Optional.
icon: PropTypes.string,
};
render() {
export default class SimpleRoomHeader extends React.PureComponent<IProps> {
public render(): JSX.Element {
let icon;
if (this.props.icon) {
icon = <img
className="mx_RoomHeader_icon" src={this.props.icon}
width="25" height="25"
className="mx_RoomHeader_icon"
src={this.props.icon}
width="25"
height="25"
/>;
}
return (
<div className="mx_RoomHeader mx_RoomHeader_wrapper" >
<div className="mx_RoomHeader mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_simpleHeader">
{ icon }
{ this.props.title }

View file

@ -14,23 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { Room } from 'matrix-js-sdk/src/models/room';
import { _t, _td } from '../../../languageHandler';
import AppTile from '../elements/AppTile';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
import WidgetUtils from '../../../utils/WidgetUtils';
import WidgetUtils, { IWidgetEvent } from '../../../utils/WidgetUtils';
import PersistedElement from "../elements/PersistedElement";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import { ContextMenu } from "../../structures/ContextMenu";
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
import { WidgetType } from "../../../widgets/WidgetType";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Action } from "../../../dispatcher/actions";
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ActionPayload } from '../../../dispatcher/payloads';
import ScalarAuthClient from '../../../ScalarAuthClient';
import GenericElementContextMenu from "../context_menus/GenericElementContextMenu";
// 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.
@ -39,29 +40,38 @@ const STICKERPICKER_Z_INDEX = 3500;
// Key to store the widget's AppTile under in PersistedElement
const PERSISTED_ELEMENT_KEY = "stickerPicker";
interface IProps {
room: Room;
showStickers: boolean;
menuPosition?: any;
setShowStickers: (showStickers: boolean) => void;
}
interface IState {
imError: string;
stickerpickerX: number;
stickerpickerY: number;
stickerpickerChevronOffset?: number;
stickerpickerWidget: IWidgetEvent;
widgetId: string;
}
@replaceableComponent("views.rooms.Stickerpicker")
export default class Stickerpicker extends React.PureComponent {
export default class Stickerpicker extends React.PureComponent<IProps, IState> {
static currentWidget;
constructor(props) {
private dispatcherRef: string;
private prevSentVisibility: boolean;
private popoverWidth = 300;
private popoverHeight = 300;
// This is loaded by _acquireScalarClient on an as-needed basis.
private scalarClient: ScalarAuthClient = null;
constructor(props: IProps) {
super(props);
this._onShowStickersClick = this._onShowStickersClick.bind(this);
this._onHideStickersClick = this._onHideStickersClick.bind(this);
this._launchManageIntegrations = this._launchManageIntegrations.bind(this);
this._removeStickerpickerWidgets = this._removeStickerpickerWidgets.bind(this);
this._updateWidget = this._updateWidget.bind(this);
this._onWidgetAction = this._onWidgetAction.bind(this);
this._onResize = this._onResize.bind(this);
this._onFinished = this._onFinished.bind(this);
this.popoverWidth = 300;
this.popoverHeight = 300;
// This is loaded by _acquireScalarClient on an as-needed basis.
this.scalarClient = null;
this.state = {
showStickers: false,
imError: null,
stickerpickerX: null,
stickerpickerY: null,
@ -70,7 +80,7 @@ export default class Stickerpicker extends React.PureComponent {
};
}
_acquireScalarClient() {
private acquireScalarClient(): Promise<void | ScalarAuthClient> {
if (this.scalarClient) return Promise.resolve(this.scalarClient);
// TODO: Pick the right manager for the widget
if (IntegrationManagers.sharedInstance().hasManager()) {
@ -79,15 +89,15 @@ export default class Stickerpicker extends React.PureComponent {
this.forceUpdate();
return this.scalarClient;
}).catch((e) => {
this._imError(_td("Failed to connect to integration manager"), e);
this.imError(_td("Failed to connect to integration manager"), e);
});
} else {
IntegrationManagers.sharedInstance().openNoManagerDialog();
}
}
async _removeStickerpickerWidgets() {
const scalarClient = await this._acquireScalarClient();
private removeStickerpickerWidgets = async (): Promise<void> => {
const scalarClient = await this.acquireScalarClient();
console.log('Removing Stickerpicker widgets');
if (this.state.widgetId) {
if (scalarClient) {
@ -103,50 +113,50 @@ export default class Stickerpicker extends React.PureComponent {
console.warn('No widget ID specified, not disabling assets');
}
this.setState({ showStickers: false });
this.props.setShowStickers(false);
WidgetUtils.removeStickerpickerWidgets().then(() => {
this.forceUpdate();
}).catch((e) => {
console.error('Failed to remove sticker picker widget', e);
});
}
};
componentDidMount() {
public componentDidMount(): void {
// Close the sticker picker when the window resizes
window.addEventListener('resize', this._onResize);
window.addEventListener('resize', this.onResize);
this.dispatcherRef = dis.register(this._onWidgetAction);
this.dispatcherRef = dis.register(this.onWidgetAction);
// Track updates to widget state in account data
MatrixClientPeg.get().on('accountData', this._updateWidget);
MatrixClientPeg.get().on('accountData', this.updateWidget);
// Initialise widget state from current account data
this._updateWidget();
this.updateWidget();
}
componentWillUnmount() {
public componentWillUnmount(): void {
const client = MatrixClientPeg.get();
if (client) client.removeListener('accountData', this._updateWidget);
if (client) client.removeListener('accountData', this.updateWidget);
window.removeEventListener('resize', this._onResize);
window.removeEventListener('resize', this.onResize);
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
}
}
componentDidUpdate(prevProps, prevState) {
this._sendVisibilityToWidget(this.state.showStickers);
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
this.sendVisibilityToWidget(this.props.showStickers);
}
_imError(errorMsg, e) {
private imError(errorMsg: string, e: Error): void {
console.error(errorMsg, e);
this.setState({
showStickers: false,
imError: _t(errorMsg),
});
this.props.setShowStickers(false);
}
_updateWidget() {
private updateWidget = (): void => {
const stickerpickerWidget = WidgetUtils.getStickerpickerWidgets()[0];
if (!stickerpickerWidget) {
Stickerpicker.currentWidget = null;
@ -175,27 +185,27 @@ export default class Stickerpicker extends React.PureComponent {
stickerpickerWidget,
widgetId: stickerpickerWidget ? stickerpickerWidget.id : null,
});
}
};
_onWidgetAction(payload) {
private onWidgetAction = (payload: ActionPayload): void => {
switch (payload.action) {
case "user_widget_updated":
this.forceUpdate();
break;
case "stickerpicker_close":
this.setState({ showStickers: false });
this.props.setShowStickers(false);
break;
case Action.AfterRightPanelPhaseChange:
case "show_left_panel":
case "hide_left_panel":
this.setState({ showStickers: false });
this.props.setShowStickers(false);
break;
}
}
};
_defaultStickerpickerContent() {
private defaultStickerpickerContent(): JSX.Element {
return (
<AccessibleButton onClick={this._launchManageIntegrations}
<AccessibleButton onClick={this.launchManageIntegrations}
className='mx_Stickers_contentPlaceholder'>
<p>{ _t("You don't currently have any stickerpacks enabled") }</p>
<p className='mx_Stickers_addLink'>{ _t("Add some now") }</p>
@ -204,29 +214,29 @@ export default class Stickerpicker extends React.PureComponent {
);
}
_errorStickerpickerContent() {
private errorStickerpickerContent(): JSX.Element {
return (
<div style={{ "text-align": "center" }} className="error">
<div style={{ textAlign: "center" }} className="error">
<p> { this.state.imError } </p>
</div>
);
}
_sendVisibilityToWidget(visible) {
private sendVisibilityToWidget(visible: boolean): void {
if (!this.state.stickerpickerWidget) return;
const messaging = WidgetMessagingStore.instance.getMessagingForId(this.state.stickerpickerWidget.id);
if (messaging && visible !== this._prevSentVisibility) {
if (messaging && visible !== this.prevSentVisibility) {
messaging.updateVisibility(visible).catch(err => {
console.error("Error updating widget visibility: ", err);
});
this._prevSentVisibility = visible;
this.prevSentVisibility = visible;
}
}
_getStickerpickerContent() {
public getStickerpickerContent(): JSX.Element {
// Handle integration manager errors
if (this.state._imError) {
return this._errorStickerpickerContent();
if (this.state.imError) {
return this.errorStickerpickerContent();
}
// Stickers
@ -239,12 +249,11 @@ export default class Stickerpicker extends React.PureComponent {
// Use a separate ReactDOM tree to render the AppTile separately so that it persists and does
// not unmount when we (a) close the sticker picker (b) switch rooms. It's properties are still
// updated.
const PersistedElement = sdk.getComponent("elements.PersistedElement");
// Load stickerpack content
if (stickerpickerWidget && stickerpickerWidget.content && stickerpickerWidget.content.url) {
// Set default name
stickerpickerWidget.content.name = stickerpickerWidget.name || _t("Stickerpack");
stickerpickerWidget.content.name = stickerpickerWidget.content.name || _t("Stickerpack");
// FIXME: could this use the same code as other apps?
const stickerApp = {
@ -275,12 +284,12 @@ export default class Stickerpicker extends React.PureComponent {
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
onEditClick={this.launchManageIntegrations}
onDeleteClick={this.removeStickerpickerWidgets}
showTitle={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
onMinimiseClick={this.onHideStickersClick}
handleMinimisePointerEvents={true}
userWidget={true}
/>
@ -290,7 +299,7 @@ export default class Stickerpicker extends React.PureComponent {
);
} else {
// Default content to show if stickerpicker widget not added
stickersContent = this._defaultStickerpickerContent();
stickersContent = this.defaultStickerpickerContent();
}
return stickersContent;
}
@ -300,7 +309,7 @@ export default class Stickerpicker extends React.PureComponent {
* Show the sticker picker overlay
* If no stickerpacks have been added, show a link to the integration manager add sticker packs page.
*/
_onShowStickersClick(e) {
private onShowStickersClick = (e: React.MouseEvent<HTMLElement>): void => {
if (!SettingsStore.getValue("integrationProvisioning")) {
// Intercept this case and spawn a warning.
return IntegrationManagers.sharedInstance().showDisabledDialog();
@ -308,7 +317,7 @@ export default class Stickerpicker extends React.PureComponent {
// XXX: Simplify by using a context menu that is positioned relative to the sticker picker button
const buttonRect = e.target.getBoundingClientRect();
const buttonRect = e.currentTarget.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
let x = buttonRect.right + window.pageXOffset - 41;
@ -324,50 +333,50 @@ export default class Stickerpicker extends React.PureComponent {
// Offset the chevron location, which is relative to the left of the context menu
// (10 = offset when context menu would not be displayed off viewport)
// (2 = context menu borders)
const stickerPickerChevronOffset = Math.max(10, 2 + window.pageXOffset + buttonRect.left - x);
const stickerpickerChevronOffset = Math.max(10, 2 + window.pageXOffset + buttonRect.left - x);
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
this.props.setShowStickers(true);
this.setState({
showStickers: true,
stickerPickerX: x,
stickerPickerY: y,
stickerPickerChevronOffset,
stickerpickerX: x,
stickerpickerY: y,
stickerpickerChevronOffset,
});
}
};
/**
* Trigger hiding of the sticker picker overlay
* @param {Event} ev Event that triggered the function call
*/
_onHideStickersClick(ev) {
if (this.state.showStickers) {
this.setState({ showStickers: false });
private onHideStickersClick = (ev: React.MouseEvent): void => {
if (this.props.showStickers) {
this.props.setShowStickers(false);
}
}
};
/**
* Called when the window is resized
*/
_onResize() {
if (this.state.showStickers) {
this.setState({ showStickers: false });
private onResize = (): void => {
if (this.props.showStickers) {
this.props.setShowStickers(false);
}
}
};
/**
* The stickers picker was hidden
*/
_onFinished() {
if (this.state.showStickers) {
this.setState({ showStickers: false });
private onFinished = (): void => {
if (this.props.showStickers) {
this.props.setShowStickers(false);
}
}
};
/**
* Launch the integration manager on the stickers integration page
*/
_launchManageIntegrations = () => {
private launchManageIntegrations = (): void => {
// TODO: Open the right integration manager for the widget
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(
@ -384,59 +393,24 @@ export default class Stickerpicker extends React.PureComponent {
}
};
render() {
let stickerPicker;
let stickersButton;
const className = classNames(
"mx_MessageComposer_button",
"mx_MessageComposer_stickers",
"mx_Stickers_hideStickers",
"mx_MessageComposer_button_highlight",
);
if (this.state.showStickers) {
// Show hide-stickers button
stickersButton =
<AccessibleButton
id='stickersButton'
key="controls_hide_stickers"
className={className}
onClick={this._onHideStickersClick}
active={this.state.showStickers.toString()}
title={_t("Hide Stickers")}
>
</AccessibleButton>;
public render(): JSX.Element {
if (!this.props.showStickers) return null;
const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu');
stickerPicker = <ContextMenu
chevronOffset={this.state.stickerPickerChevronOffset}
chevronFace="bottom"
left={this.state.stickerPickerX}
top={this.state.stickerPickerY}
menuWidth={this.popoverWidth}
menuHeight={this.popoverHeight}
onFinished={this._onFinished}
menuPaddingTop={0}
menuPaddingLeft={0}
menuPaddingRight={0}
zIndex={STICKERPICKER_Z_INDEX}
>
<GenericElementContextMenu element={this._getStickerpickerContent()} onResize={this._onFinished} />
</ContextMenu>;
} else {
// Show show-stickers button
stickersButton =
<AccessibleTooltipButton
id='stickersButton'
key="controls_show_stickers"
className="mx_MessageComposer_button mx_MessageComposer_stickers"
onClick={this._onShowStickersClick}
title={_t("Show Stickers")}
>
</AccessibleTooltipButton>;
}
return <React.Fragment>
{ stickersButton }
{ stickerPicker }
</React.Fragment>;
return <ContextMenu
chevronOffset={this.state.stickerpickerChevronOffset}
chevronFace={ChevronFace.Bottom}
left={this.state.stickerpickerX}
top={this.state.stickerpickerY}
menuWidth={this.popoverWidth}
menuHeight={this.popoverHeight}
onFinished={this.onFinished}
menuPaddingTop={0}
menuPaddingLeft={0}
menuPaddingRight={0}
zIndex={STICKERPICKER_Z_INDEX}
{...this.props.menuPosition}
>
<GenericElementContextMenu element={this.getStickerpickerContent()} onResize={this.onFinished} />
</ContextMenu>;
}
}

View file

@ -1,7 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 New Vector Ltd
Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,29 +15,30 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.TopUnreadMessagesBar")
export default class TopUnreadMessagesBar extends React.Component {
static propTypes = {
onScrollUpClick: PropTypes.func,
onCloseClick: PropTypes.func,
};
interface IProps {
onScrollUpClick?: (e: React.MouseEvent) => void;
onCloseClick?: (e: React.MouseEvent) => void;
}
render() {
@replaceableComponent("views.rooms.TopUnreadMessagesBar")
export default class TopUnreadMessagesBar extends React.PureComponent<IProps> {
public render(): JSX.Element {
return (
<div className="mx_TopUnreadMessagesBar">
<AccessibleButton className="mx_TopUnreadMessagesBar_scrollUp"
<AccessibleButton
className="mx_TopUnreadMessagesBar_scrollUp"
title={_t('Jump to first unread message.')}
onClick={this.props.onScrollUpClick}>
</AccessibleButton>
<AccessibleButton className="mx_TopUnreadMessagesBar_markAsRead"
onClick={this.props.onScrollUpClick}
/>
<AccessibleButton
className="mx_TopUnreadMessagesBar_markAsRead"
title={_t('Mark all as read')}
onClick={this.props.onCloseClick}>
</AccessibleButton>
onClick={this.props.onCloseClick}
/>
</div>
);
}

View file

@ -17,13 +17,9 @@ limitations under the License.
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler";
import React, { ReactNode } from "react";
import {
RecordingState,
VoiceRecording,
} from "../../../voice/VoiceRecording";
import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import classNames from "classnames";
import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import LiveRecordingClock from "../audio_messages/LiveRecordingClock";
@ -34,6 +30,11 @@ import { MsgType } from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
import NotificationBadge from "./NotificationBadge";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import InlineSpinner from "../elements/InlineSpinner";
import { PlaybackManager } from "../../../audio/PlaybackManager";
interface IProps {
room: Room;
@ -42,6 +43,7 @@ interface IProps {
interface IState {
recorder?: VoiceRecording;
recordingPhase?: RecordingState;
didUploadFail?: boolean;
}
/**
@ -69,9 +71,19 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
await this.state.recorder.stop();
let upload: IUpload;
try {
const upload = await this.state.recorder.upload(this.props.room.roomId);
upload = await this.state.recorder.upload(this.props.room.roomId);
} catch (e) {
console.error("Error uploading voice message:", e);
// Flag error and move on. The recording phase will be reset by the upload function.
this.setState({ didUploadFail: true });
return; // don't dispose the recording: the user has a chance to re-upload
}
try {
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
"body": "Voice message",
@ -104,12 +116,11 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
});
} catch (e) {
console.error("Error sending/uploading voice message:", e);
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
title: _t('Upload Failed'),
description: _t("The voice message failed to upload."),
});
return; // don't dispose the recording so the user can retry, maybe
console.error("Error sending voice message:", e);
// Voice message should be in the timeline at this point, so let other things take care
// of error handling. We also shouldn't need the recording anymore, so fall through to
// disposal.
}
await this.disposeRecording();
}
@ -118,14 +129,14 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
await VoiceRecordingStore.instance.disposeRecording();
// Reset back to no recording, which means no phase (ie: restart component entirely)
this.setState({ recorder: null, recordingPhase: null });
this.setState({ recorder: null, recordingPhase: null, didUploadFail: false });
}
private onCancel = async () => {
await this.disposeRecording();
};
private onRecordStartEndClick = async () => {
public onRecordStartEndClick = async () => {
if (this.state.recorder) {
await this.state.recorder.stop();
return;
@ -166,6 +177,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
}
try {
// stop any noises which might be happening
await PlaybackManager.instance.pauseAllExcept(null);
const recorder = VoiceRecordingStore.instance.startRecording();
await recorder.start();
@ -189,7 +203,6 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
if (this.state.recordingPhase !== RecordingState.Started) {
// TODO: @@ TR: Should we disable this during upload? What does a failed upload look like?
return <RecordingPlayback playback={this.state.recorder.getPlayback()} />;
}
@ -201,44 +214,56 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
}
public render(): ReactNode {
let recordingInfo;
let deleteButton;
if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
const classes = classNames({
'mx_MessageComposer_button': !this.state.recorder,
'mx_MessageComposer_voiceMessage': !this.state.recorder,
'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
});
if (!this.state.recordingPhase) return null;
let tooltip = _t("Record a voice message");
let stopBtn;
let deleteButton;
if (this.state.recordingPhase === RecordingState.Started) {
let tooltip = _t("Send voice message");
if (!!this.state.recorder) {
tooltip = _t("Stop the recording");
tooltip = _t("Stop recording");
}
let stopOrRecordBtn = <AccessibleTooltipButton
className={classes}
stopBtn = <AccessibleTooltipButton
className="mx_VoiceRecordComposerTile_stop"
onClick={this.onRecordStartEndClick}
title={tooltip}
/>;
if (this.state.recorder && !this.state.recorder?.isRecording) {
stopOrRecordBtn = null;
stopBtn = null;
}
recordingInfo = stopOrRecordBtn;
}
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
deleteButton = <AccessibleTooltipButton
className='mx_VoiceRecordComposerTile_delete'
title={_t("Delete recording")}
title={_t("Delete")}
onClick={this.onCancel}
/>;
}
let uploadIndicator;
if (this.state.recordingPhase === RecordingState.Uploading) {
uploadIndicator = <span className='mx_VoiceRecordComposerTile_uploadingState'>
<InlineSpinner w={16} h={16} />
</span>;
} else if (this.state.didUploadFail && this.state.recordingPhase === RecordingState.Ended) {
uploadIndicator = <span className='mx_VoiceRecordComposerTile_failedState'>
<span className='mx_VoiceRecordComposerTile_uploadState_badge'>
{ /* Need to stick the badge in a span to ensure it doesn't create a block component */ }
<NotificationBadge
notification={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
/>
</span>
<span className='text-warning'>{ _t("Failed to send") }</span>
</span>;
}
return (<>
{ uploadIndicator }
{ deleteButton }
{ stopBtn }
{ this.renderWaveformArea() }
{ recordingInfo }
</>);
}
}