From c05eceef7f3560adad8354c0aef0f32d38684718 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 16 Feb 2021 17:56:09 +0000 Subject: [PATCH 01/31] Rework composer autocomplete to be smarter and not trap tab --- src/components/views/rooms/Autocomplete.tsx | 34 ++++++++++++------- .../views/rooms/BasicMessageComposer.tsx | 26 ++++++++++---- src/editor/autocomplete.ts | 5 ++- 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index 15af75084a..e62a5a9bd6 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -24,8 +24,6 @@ import {Room} from 'matrix-js-sdk/src/models/room'; import SettingsStore from "../../../settings/SettingsStore"; import Autocompleter from '../../../autocomplete/Autocompleter'; -const COMPOSER_SELECTED = 0; - export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`; interface IProps { @@ -68,7 +66,7 @@ export default class Autocomplete extends React.PureComponent { 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, @@ -112,7 +110,7 @@ export default class Autocomplete extends React.PureComponent { completions: [], completionList: [], // Reset selected completion - selectionOffset: COMPOSER_SELECTED, + selectionOffset: 1, // Hide the autocomplete box hide: true, }); @@ -148,26 +146,31 @@ export default class Autocomplete extends React.PureComponent { 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(this.state.completionList[selectionOffset - 1], selectionOffset - 1); + } + } this.setState({ completions, @@ -193,8 +196,8 @@ export default class Autocomplete extends React.PureComponent { 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 { @@ -213,7 +216,7 @@ export default class Autocomplete extends React.PureComponent { hide = () => { this.setState({ hide: true, - selectionOffset: 0, + selectionOffset: 1, completions: [], completionList: [], }); @@ -232,8 +235,13 @@ export default class Autocomplete extends React.PureComponent { }); } + onConfirmCompletion = () => { + this.onCompletionClicked(this.state.selectionOffset); + } + onCompletionClicked = (selectionOffset: number): boolean => { - if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) { + const count = this.countCompletions(); + if (count === 0 || selectionOffset < 1 || selectionOffset > count) { return false; } diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 017ce77166..3c82f75b33 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -126,6 +126,7 @@ export default class BasicMessageEditor extends React.Component super(props); this.state = { showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"), + showVisualBell: false, }; this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null, @@ -201,7 +202,11 @@ export default class BasicMessageEditor extends React.Component 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; @@ -490,6 +495,7 @@ export default class BasicMessageEditor extends React.Component } break; case Key.TAB: + case Key.ENTER: if (!metaOrAltPressed) { autoComplete.onTab(event); handled = true; @@ -504,7 +510,7 @@ export default class BasicMessageEditor extends React.Component default: return; // don't preventDefault on anything else } - } else if (event.key === Key.TAB) { + } else if (!this.props.model.isEmpty && !this.state.showVisualBell && event.key === Key.TAB) { this.tabCompleteName(event); handled = true; } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { @@ -545,6 +551,8 @@ export default class BasicMessageEditor extends React.Component this.setState({showVisualBell: true}); model.autoComplete.close(); } + } else { + this.setState({showVisualBell: true}); } } catch (err) { console.error(err); @@ -562,7 +570,7 @@ export default class BasicMessageEditor extends React.Component private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => { this.modifiedFlag = true; - this.props.model.autoComplete.onComponentSelectionChange(completion); + // this.props.model.autoComplete.onComponentSelectionChange(completion); this.setState({completionIndex}); }; @@ -679,6 +687,11 @@ export default class BasicMessageEditor extends React.Component }; const {completionIndex} = this.state; + const hasAutocomplete = Boolean(this.state.autoComplete); + let activeDescendant; + if (hasAutocomplete && completionIndex >= 0) { + activeDescendant = generateCompletionDomId(completionIndex); + } return (
{ autoComplete } @@ -697,10 +710,11 @@ export default class BasicMessageEditor extends React.Component 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" />
); diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts index d8cea961d4..4633cd56c2 100644 --- a/src/editor/autocomplete.ts +++ b/src/editor/autocomplete.ts @@ -74,10 +74,9 @@ export default class AutocompleteWrapperModel { if (acComponent.countCompletions() === 0) { // Force completions to show for the text currently entered await acComponent.forceComplete(); - // Select the first item by moving "down" - await acComponent.moveSelection(+1); } else { - await acComponent.moveSelection(e.shiftKey ? -1 : +1); + await acComponent.onConfirmCompletion(); + this.updateCallback({close: true}); } } From 9e2974d84d70cbdf4e01e8c813b53a3d73fd6133 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 18 Feb 2021 10:25:25 +0000 Subject: [PATCH 02/31] Improve composer keyboard trapping --- src/components/structures/LoggedInView.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index c76cd7cee7..b04d840fb0 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -495,24 +495,24 @@ class LoggedInView extends React.Component { if (handled) { ev.stopPropagation(); ev.preventDefault(); - } else if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { + } else if (!isModifier && !ev.ctrlKey && !ev.metaKey) { // The above condition is crafted to _allow_ characters with Shift // already pressed (but not the Shift key down itself). - const isClickShortcut = ev.target !== document.body && (ev.key === Key.SPACE || ev.key === Key.ENTER); - // Do not capture the context menu key to improve keyboard accessibility - if (ev.key === Key.CONTEXT_MENU) { - return; - } + // We explicitly allow alt to be held due to it being a common accent modifier. + // XXX: Forwarding Dead keys in this way does not work as intended but better to at least + // move focus to the composer so the user can re-type the dead key correctly. + const isPrintable = ev.key.length === 1 || ev.key === "Dead"; - if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) { + // If the user is entering a printable character outside of an input field + // redirect it to the composer for them. + if (!isClickShortcut && isPrintable && !canElementReceiveInput(ev.target)) { // synchronous dispatch so we focus before key generates input dis.fire(Action.FocusComposer, true); ev.stopPropagation(); - // we should *not* preventDefault() here as - // that would prevent typing in the now-focussed composer + // we should *not* preventDefault() here as that would prevent typing in the now-focused composer } } }; From 9463fda1c10a26542bbfdd6ae0556ed8aa0fbb02 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 18 Feb 2021 10:55:24 +0000 Subject: [PATCH 03/31] Improve VoiceOver & WebKit accessibility support Based on https://bugs.webkit.org/show_bug.cgi?id=167671#c15 (workaround) --- src/autocomplete/AutocompleteProvider.tsx | 17 +++++------------ src/autocomplete/CommandProvider.tsx | 2 +- src/autocomplete/CommunityProvider.tsx | 2 +- src/autocomplete/DuckDuckGoProvider.tsx | 2 +- src/autocomplete/EmojiProvider.tsx | 2 +- src/autocomplete/NotifProvider.tsx | 2 +- src/autocomplete/RoomProvider.tsx | 2 +- src/autocomplete/UserProvider.tsx | 2 +- src/components/views/rooms/Autocomplete.tsx | 4 ++-- 9 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/autocomplete/AutocompleteProvider.tsx b/src/autocomplete/AutocompleteProvider.tsx index a40ce7144d..cc958546e1 100644 --- a/src/autocomplete/AutocompleteProvider.tsx +++ b/src/autocomplete/AutocompleteProvider.tsx @@ -27,11 +27,11 @@ export interface ICommand { }; } -export default class AutocompleteProvider { +export default abstract class AutocompleteProvider { commandRegex: RegExp; forcedCommandRegex: RegExp; - constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) { + protected constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) { if (commandRegex) { if (!commandRegex.global) { throw new Error('commandRegex must have global flag set'); @@ -93,18 +93,11 @@ export default class AutocompleteProvider { }; } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { - return []; - } + abstract getCompletions(query: string, selection: ISelectionRange, force: boolean): Promise; - getName(): string { - return 'Default Provider'; - } + abstract getName(): string; - renderCompletions(completions: React.ReactNode[]): React.ReactNode | null { - console.error('stub; should be implemented in subclasses'); - return null; - } + abstract renderCompletions(completions: React.ReactNode[]): React.ReactNode | null; // Whether we should provide completions even if triggered forcefully, without a sigil. shouldForceComplete(): boolean { diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index c2d1290e08..7698dfcd15 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -91,7 +91,7 @@ export default class CommandProvider extends AutocompleteProvider { return (
{ completions } diff --git a/src/autocomplete/CommunityProvider.tsx b/src/autocomplete/CommunityProvider.tsx index ebf5d536ec..8e2d2789cd 100644 --- a/src/autocomplete/CommunityProvider.tsx +++ b/src/autocomplete/CommunityProvider.tsx @@ -112,7 +112,7 @@ export default class CommunityProvider extends AutocompleteProvider { return (
{ completions } diff --git a/src/autocomplete/DuckDuckGoProvider.tsx b/src/autocomplete/DuckDuckGoProvider.tsx index e63f7255dc..a16c82aaf9 100644 --- a/src/autocomplete/DuckDuckGoProvider.tsx +++ b/src/autocomplete/DuckDuckGoProvider.tsx @@ -99,7 +99,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { return (
{ completions } diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 705474f8d0..4a237fe091 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -140,7 +140,7 @@ export default class EmojiProvider extends AutocompleteProvider { return (
{ completions } diff --git a/src/autocomplete/NotifProvider.tsx b/src/autocomplete/NotifProvider.tsx index ef1823c0ca..e948f8a985 100644 --- a/src/autocomplete/NotifProvider.tsx +++ b/src/autocomplete/NotifProvider.tsx @@ -66,7 +66,7 @@ export default class NotifProvider extends AutocompleteProvider { return (
{ completions } diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index 74deacf61f..6614615436 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -123,7 +123,7 @@ export default class RoomProvider extends AutocompleteProvider { return (
{ completions } diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 32eea55b0b..6d909d38ad 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -178,7 +178,7 @@ export default class UserProvider extends AutocompleteProvider { return (
{ completions } diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index e62a5a9bd6..cdea607cea 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -298,7 +298,7 @@ export default class Autocomplete extends React.PureComponent { return completions.length > 0 ? ( -
+
{ completionResult.provider.getName() }
{ completionResult.provider.renderCompletions(completions) }
@@ -306,7 +306,7 @@ export default class Autocomplete extends React.PureComponent { }).filter((completion) => !!completion); return !this.state.hide && renderedCompletions.length > 0 ? ( -
+
{ renderedCompletions }
) : null; From 60e7089c770592b90194f478c57a3c587a71bad0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 11 May 2021 11:14:21 +0100 Subject: [PATCH 04/31] post-merge fixes, the new keybindings stuff made it messy --- src/KeyBindingsDefaults.ts | 14 ++-- src/KeyBindingsManager.ts | 12 ++- .../views/rooms/BasicMessageComposer.tsx | 75 ++++++++++--------- src/editor/autocomplete.ts | 6 +- 4 files changed, 52 insertions(+), 55 deletions(-) diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 63c4ac0f86..1270491d08 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -161,31 +161,29 @@ const messageComposerBindings = (): KeyBinding[] => { const autocompleteBindings = (): KeyBinding[] => { return [ { - action: AutocompleteAction.CompleteOrNextSelection, + action: AutocompleteAction.ForceComplete, keyCombo: { key: Key.TAB, }, }, { - action: AutocompleteAction.CompleteOrNextSelection, + action: AutocompleteAction.ForceComplete, keyCombo: { key: Key.TAB, ctrlKey: true, }, }, { - action: AutocompleteAction.CompleteOrPrevSelection, + action: AutocompleteAction.Complete, keyCombo: { - key: Key.TAB, - shiftKey: true, + key: Key.ENTER, }, }, { - action: AutocompleteAction.CompleteOrPrevSelection, + action: AutocompleteAction.Complete, keyCombo: { - key: Key.TAB, + key: Key.ENTER, ctrlKey: true, - shiftKey: true, }, }, { diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index aac14bde20..4a84b13257 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -52,13 +52,11 @@ export enum MessageComposerAction { /** Actions for text editing autocompletion */ export enum AutocompleteAction { - /** - * Select previous selection or, if the autocompletion window is not shown, open the window and select the first - * selection. - */ - CompleteOrPrevSelection = 'ApplySelection', - /** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */ - CompleteOrNextSelection = 'CompleteOrNextSelection', + /** Accepts chosen autocomplete selection */ + Complete = 'Complete', + /** Accepts chosen autocomplete selection or, + * if the autocompletion window is not shown, open the window and select the first selection */ + ForceComplete = 'ForceComplete', /** Move to the previous autocomplete selection */ PrevSelection = 'PrevSelection', /** Move to the next autocomplete selection */ diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 5377e08b5e..a2cae654f3 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -434,6 +434,45 @@ export default class BasicMessageEditor extends React.Component private onKeyDown = (event: React.KeyboardEvent) => { const model = this.props.model; let handled = false; + + const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event); + if (model.autoComplete && 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) { + // 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: @@ -485,42 +524,6 @@ export default class BasicMessageEditor extends React.Component 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(); diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts index 86260963e5..fe09406ca1 100644 --- a/src/editor/autocomplete.ts +++ b/src/editor/autocomplete.ts @@ -64,7 +64,8 @@ export default class AutocompleteWrapperModel { return ac && ac.countCompletions() > 0; } - public onEnter() { + public async confirmCompletion() { + await this.getAutocompleterComponent().onConfirmCompletion(); this.updateCallback({close: true}); } @@ -76,9 +77,6 @@ export default class AutocompleteWrapperModel { if (acComponent.countCompletions() === 0) { // Force completions to show for the text currently entered await acComponent.forceComplete(); - } else { - await acComponent.onConfirmCompletion(); - this.updateCallback({close: true}); } } From 78f569de94b623021aed496a6c848b4d6ec6e4bb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 18 May 2021 12:56:23 +0100 Subject: [PATCH 05/31] delint --- src/autocomplete/AutocompleteProvider.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/autocomplete/AutocompleteProvider.tsx b/src/autocomplete/AutocompleteProvider.tsx index 38dd34a047..1924ea48a7 100644 --- a/src/autocomplete/AutocompleteProvider.tsx +++ b/src/autocomplete/AutocompleteProvider.tsx @@ -98,9 +98,7 @@ export default abstract class AutocompleteProvider { selection: ISelectionRange, force: boolean, limit: number, - ): Promise { - return []; - } + ): Promise; abstract getName(): string; From 28eaac0ef808bb5e7604faef3aa240a6c4c2ae54 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 20 May 2021 22:25:19 +0100 Subject: [PATCH 06/31] remove dead code and fix some types --- src/components/views/rooms/Autocomplete.tsx | 8 ++++---- .../views/rooms/BasicMessageComposer.tsx | 5 ++--- src/editor/autocomplete.ts | 20 ------------------- src/editor/model.ts | 2 +- 4 files changed, 7 insertions(+), 28 deletions(-) diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index 1eb2be473b..d9b4268917 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -33,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; @@ -172,7 +172,7 @@ export default class Autocomplete extends React.PureComponent { if (anyMatches) { hide = false; if (this.props.onSelectionChange) { - this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1); + this.props.onSelectionChange(selectionOffset - 1); } } @@ -258,7 +258,7 @@ export default class Autocomplete extends React.PureComponent { setSelection(selectionOffset: number) { this.setState({selectionOffset, hide: false}); if (this.props.onSelectionChange) { - this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1); + this.props.onSelectionChange(selectionOffset - 1); } } diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index a2cae654f3..16332fda8f 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -575,10 +575,9 @@ export default class BasicMessageEditor extends React.Component this.props.model.autoComplete.onComponentConfirm(completion); }; - private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => { + private onAutoCompleteSelectionChange = (completionIndex: number) => { this.modifiedFlag = true; - // this.props.model.autoComplete.onComponentSelectionChange(completion); - this.setState({completionIndex}); + this.setState({ completionIndex }); }; private configureEmoticonAutoReplace = () => { diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts index fe09406ca1..ea943c3f2c 100644 --- a/src/editor/autocomplete.ts +++ b/src/editor/autocomplete.ts @@ -32,7 +32,6 @@ export type GetAutocompleterComponent = () => Autocomplete; export type UpdateQuery = (test: string) => Promise; export default class AutocompleteWrapperModel { - private queryPart: Part; private partIndex: number; constructor( @@ -45,10 +44,6 @@ export default class AutocompleteWrapperModel { public onEscape(e: KeyboardEvent) { this.getAutocompleterComponent().onEscape(e); - this.updateCallback({ - replaceParts: [this.partCreator.plain(this.queryPart.text)], - close: true, - }); } public close() { @@ -89,25 +84,10 @@ export default class AutocompleteWrapperModel { } public onPartUpdate(part: Part, pos: DocumentPosition) { - // cache the typed value and caret here - // so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text) - this.queryPart = part; this.partIndex = pos.index; return this.updateQuery(part.text); } - public onComponentSelectionChange(completion: ICompletion) { - if (!completion) { - this.updateCallback({ - replaceParts: [this.queryPart], - }); - } else { - this.updateCallback({ - replaceParts: this.partForCompletion(completion), - }); - } - } - public onComponentConfirm(completion: ICompletion) { this.updateCallback({ replaceParts: this.partForCompletion(completion), diff --git a/src/editor/model.ts b/src/editor/model.ts index f1b6f90957..90765da047 100644 --- a/src/editor/model.ts +++ b/src/editor/model.ts @@ -237,7 +237,7 @@ export default class EditorModel { } } } - // not _autoComplete, only there if active part is autocomplete part + // not autoComplete, only there if active part is autocomplete part if (this.autoComplete) { return this.autoComplete.onPartUpdate(part, pos); } From b88d67bb005b3b02ae9f31a9c35ff63dfd5a8cba Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 7 Jul 2021 11:08:53 +0100 Subject: [PATCH 07/31] Convert SearchResult, InteractiveAuth, PushProcessor and Scheduler to Typescript --- src/components/structures/InteractiveAuth.js | 300 ------------------ src/components/structures/InteractiveAuth.tsx | 300 ++++++++++++++++++ .../auth/InteractiveAuthEntryComponents.tsx | 51 ++- .../views/dialogs/DeactivateAccountDialog.tsx | 10 +- .../controllers/NotificationControllers.ts | 4 +- 5 files changed, 331 insertions(+), 334 deletions(-) delete mode 100644 src/components/structures/InteractiveAuth.js create mode 100644 src/components/structures/InteractiveAuth.tsx diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js deleted file mode 100644 index 9ff830f66a..0000000000 --- a/src/components/structures/InteractiveAuth.js +++ /dev/null @@ -1,300 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd. -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth"; -import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; - -import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents'; - -import * as sdk from '../../index'; -import { replaceableComponent } from "../../utils/replaceableComponent"; - -export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); - -@replaceableComponent("structures.InteractiveAuthComponent") -export default class InteractiveAuthComponent extends React.Component { - static propTypes = { - // matrix client to use for UI auth requests - matrixClient: PropTypes.object.isRequired, - - // response from initial request. If not supplied, will do a request on - // mount. - authData: PropTypes.shape({ - flows: PropTypes.array, - params: PropTypes.object, - session: PropTypes.string, - }), - - // callback - makeRequest: PropTypes.func.isRequired, - - // callback called when the auth process has finished, - // successfully or unsuccessfully. - // @param {bool} status True if the operation requiring - // auth was completed sucessfully, false if canceled. - // @param {object} result The result of the authenticated call - // if successful, otherwise the error object. - // @param {object} extra Additional information about the UI Auth - // process: - // * emailSid {string} If email auth was performed, the sid of - // the auth session. - // * clientSecret {string} The client secret used in auth - // sessions with the ID server. - onAuthFinished: PropTypes.func.isRequired, - - // Inputs provided by the user to the auth process - // and used by various stages. As passed to js-sdk - // interactive-auth - inputs: PropTypes.object, - - // As js-sdk interactive-auth - requestEmailToken: PropTypes.func, - sessionId: PropTypes.string, - clientSecret: PropTypes.string, - emailSid: PropTypes.string, - - // If true, poll to see if the auth flow has been completed - // out-of-band - poll: PropTypes.bool, - - // If true, components will be told that the 'Continue' button - // is managed by some other party and should not be managed by - // the component itself. - continueIsManaged: PropTypes.bool, - - // Called when the stage changes, or the stage's phase changes. First - // argument is the stage, second is the phase. Some stages do not have - // phases and will be counted as 0 (numeric). - onStagePhaseChange: PropTypes.func, - - // continueText and continueKind are passed straight through to the AuthEntryComponent. - continueText: PropTypes.string, - continueKind: PropTypes.string, - }; - - constructor(props) { - super(props); - - this.state = { - authStage: null, - busy: false, - errorText: null, - stageErrorText: null, - submitButtonEnabled: false, - }; - - this._unmounted = false; - this._authLogic = new InteractiveAuth({ - authData: this.props.authData, - doRequest: this._requestCallback, - busyChanged: this._onBusyChanged, - inputs: this.props.inputs, - stateUpdated: this._authStateUpdated, - matrixClient: this.props.matrixClient, - sessionId: this.props.sessionId, - clientSecret: this.props.clientSecret, - emailSid: this.props.emailSid, - requestEmailToken: this._requestEmailToken, - }); - - this._intervalId = null; - if (this.props.poll) { - this._intervalId = setInterval(() => { - this._authLogic.poll(); - }, 2000); - } - - this._stageComponent = createRef(); - } - - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount() { // eslint-disable-line camelcase - this._authLogic.attemptAuth().then((result) => { - const extra = { - emailSid: this._authLogic.getEmailSid(), - clientSecret: this._authLogic.getClientSecret(), - }; - this.props.onAuthFinished(true, result, extra); - }).catch((error) => { - this.props.onAuthFinished(false, error); - console.error("Error during user-interactive auth:", error); - if (this._unmounted) { - return; - } - - const msg = error.message || error.toString(); - this.setState({ - errorText: msg, - }); - }); - } - - componentWillUnmount() { - this._unmounted = true; - - if (this._intervalId !== null) { - clearInterval(this._intervalId); - } - } - - _requestEmailToken = async (...args) => { - this.setState({ - busy: true, - }); - try { - return await this.props.requestEmailToken(...args); - } finally { - this.setState({ - busy: false, - }); - } - }; - - tryContinue = () => { - if (this._stageComponent.current && this._stageComponent.current.tryContinue) { - this._stageComponent.current.tryContinue(); - } - }; - - _authStateUpdated = (stageType, stageState) => { - const oldStage = this.state.authStage; - this.setState({ - busy: false, - authStage: stageType, - stageState: stageState, - errorText: stageState.error, - }, () => { - if (oldStage !== stageType) { - this._setFocus(); - } else if ( - !stageState.error && this._stageComponent.current && - this._stageComponent.current.attemptFailed - ) { - this._stageComponent.current.attemptFailed(); - } - }); - }; - - _requestCallback = (auth) => { - // This wrapper just exists because the js-sdk passes a second - // 'busy' param for backwards compat. This throws the tests off - // so discard it here. - return this.props.makeRequest(auth); - }; - - _onBusyChanged = (busy) => { - // if we've started doing stuff, reset the error messages - if (busy) { - this.setState({ - busy: true, - errorText: null, - stageErrorText: null, - }); - } - // The JS SDK eagerly reports itself as "not busy" right after any - // immediate work has completed, but that's not really what we want at - // the UI layer, so we ignore this signal and show a spinner until - // there's a new screen to show the user. This is implemented by setting - // `busy: false` in `_authStateUpdated`. - // See also https://github.com/vector-im/element-web/issues/12546 - }; - - _setFocus() { - if (this._stageComponent.current && this._stageComponent.current.focus) { - this._stageComponent.current.focus(); - } - } - - _submitAuthDict = authData => { - this._authLogic.submitAuthDict(authData); - }; - - _onPhaseChange = newPhase => { - if (this.props.onStagePhaseChange) { - this.props.onStagePhaseChange(this.state.authStage, newPhase || 0); - } - }; - - _onStageCancel = () => { - this.props.onAuthFinished(false, ERROR_USER_CANCELLED); - }; - - _renderCurrentStage() { - const stage = this.state.authStage; - if (!stage) { - if (this.state.busy) { - const Loader = sdk.getComponent("elements.Spinner"); - return ; - } else { - return null; - } - } - - const StageComponent = getEntryComponentForLoginType(stage); - return ( - - ); - } - - _onAuthStageFailed = e => { - this.props.onAuthFinished(false, e); - }; - - _setEmailSid = sid => { - this._authLogic.setEmailSid(sid); - }; - - render() { - let error = null; - if (this.state.errorText) { - error = ( -
- { this.state.errorText } -
- ); - } - - return ( -
-
- { this._renderCurrentStage() } - { error } -
-
- ); - } -} diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx new file mode 100644 index 0000000000..017e34184f --- /dev/null +++ b/src/components/structures/InteractiveAuth.tsx @@ -0,0 +1,300 @@ +/* +Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + AuthType, + IAuthData, + IAuthDict, + IInputs, + InteractiveAuth, + IStageStatus, +} from "matrix-js-sdk/src/interactive-auth"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import React, { createRef } from 'react'; + +import getEntryComponentForLoginType, { IStageComponent } from '../views/auth/InteractiveAuthEntryComponents'; +import Spinner from "../views/elements/Spinner"; +import { replaceableComponent } from "../../utils/replaceableComponent"; + +export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); + +interface IProps { + // matrix client to use for UI auth requests + matrixClient: MatrixClient; + // response from initial request. If not supplied, will do a request on mount. + authData: IAuthData; + // Inputs provided by the user to the auth process + // and used by various stages. As passed to js-sdk + // interactive-auth + inputs?: IInputs; + sessionId?: string; + clientSecret?: string; + emailSid?: string; + // If true, poll to see if the auth flow has been completed out-of-band + poll?: boolean; + // If true, components will be told that the 'Continue' button + // is managed by some other party and should not be managed by + // the component itself. + continueIsManaged?: boolean; + // continueText and continueKind are passed straight through to the AuthEntryComponent. + continueText?: string; + continueKind?: string; + // callback + makeRequest(auth: IAuthData): Promise; + // callback called when the auth process has finished, + // successfully or unsuccessfully. + // @param {boolean} status True if the operation requiring + // auth was completed successfully, false if canceled. + // @param {object} result The result of the authenticated call + // if successful, otherwise the error object. + // @param {object} extra Additional information about the UI Auth + // process: + // * emailSid {string} If email auth was performed, the sid of + // the auth session. + // * clientSecret {string} The client secret used in auth + // sessions with the ID server. + onAuthFinished( + status: boolean, + result: IAuthData | Error, + extra?: { emailSid?: string, clientSecret?: string }, + ): void; + // As js-sdk interactive-auth + requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>, + // Called when the stage changes, or the stage's phase changes. First + // argument is the stage, second is the phase. Some stages do not have + // phases and will be counted as 0 (numeric). + onStagePhaseChange?(stage: string, phase: string | number): void, +} + +interface IState { + authStage?: AuthType; + stageState?: IStageStatus; + busy: boolean; + errorText?: string; + stageErrorText?: string; + submitButtonEnabled: boolean; +} + +@replaceableComponent("structures.InteractiveAuthComponent") +export default class InteractiveAuthComponent extends React.Component { + private readonly authLogic: InteractiveAuth; + private readonly _intervalId: NodeJS.Timeout = null; + private readonly stageComponent = createRef(); + + private unmounted = false; + + constructor(props) { + super(props); + + this.state = { + authStage: null, + busy: false, + errorText: null, + stageErrorText: null, + submitButtonEnabled: false, + }; + + this.authLogic = new InteractiveAuth({ + authData: this.props.authData, + doRequest: this.requestCallback, + busyChanged: this.onBusyChanged, + inputs: this.props.inputs, + stateUpdated: this.authStateUpdated, + matrixClient: this.props.matrixClient, + sessionId: this.props.sessionId, + clientSecret: this.props.clientSecret, + emailSid: this.props.emailSid, + requestEmailToken: this.requestEmailToken, + }); + + if (this.props.poll) { + this._intervalId = setInterval(() => { + this.authLogic.poll(); + }, 2000); + } + } + + // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs + UNSAFE_componentWillMount() { // eslint-disable-line camelcase + this.authLogic.attemptAuth().then((result) => { + const extra = { + emailSid: this.authLogic.getEmailSid(), + clientSecret: this.authLogic.getClientSecret(), + }; + this.props.onAuthFinished(true, result, extra); + }).catch((error) => { + this.props.onAuthFinished(false, error); + console.error("Error during user-interactive auth:", error); + if (this.unmounted) { + return; + } + + const msg = error.message || error.toString(); + this.setState({ + errorText: msg, + }); + }); + } + + componentWillUnmount() { + this.unmounted = true; + + if (this._intervalId !== null) { + clearInterval(this._intervalId); + } + } + + private requestEmailToken = async ( + email: string, + secret: string, + attempt: number, + session: string, + ): Promise<{sid: string}> => { + this.setState({ + busy: true, + }); + try { + return await this.props.requestEmailToken(email, secret, attempt, session); + } finally { + this.setState({ + busy: false, + }); + } + }; + + private tryContinue = (): void => { + this.stageComponent.current?.tryContinue?.(); + }; + + private authStateUpdated = (stageType: AuthType, stageState: IStageStatus): void => { + const oldStage = this.state.authStage; + this.setState({ + busy: false, + authStage: stageType, + stageState: stageState, + errorText: stageState.error, + }, () => { + if (oldStage !== stageType) { + this.setFocus(); + } else if (!stageState.error) { + this.stageComponent.current?.attemptFailed?.(); + } + }); + }; + + private requestCallback = (auth: IAuthData, background: boolean): Promise => { + // This wrapper just exists because the js-sdk passes a second + // 'busy' param for backwards compat. This throws the tests off + // so discard it here. + return this.props.makeRequest(auth); + }; + + private onBusyChanged = (busy: boolean): void => { + // if we've started doing stuff, reset the error messages + if (busy) { + this.setState({ + busy: true, + errorText: null, + stageErrorText: null, + }); + } + // The JS SDK eagerly reports itself as "not busy" right after any + // immediate work has completed, but that's not really what we want at + // the UI layer, so we ignore this signal and show a spinner until + // there's a new screen to show the user. This is implemented by setting + // `busy: false` in `authStateUpdated`. + // See also https://github.com/vector-im/element-web/issues/12546 + }; + + private setFocus(): void { + this.stageComponent.current?.focus?.(); + } + + private submitAuthDict = (authData: IAuthDict): void => { + this.authLogic.submitAuthDict(authData); + }; + + private onPhaseChange = (newPhase: number): void => { + this.props.onStagePhaseChange?.(this.state.authStage, newPhase || 0); + }; + + private onStageCancel = (): void => { + this.props.onAuthFinished(false, ERROR_USER_CANCELLED); + }; + + private renderCurrentStage() { + const stage = this.state.authStage; + if (!stage) { + if (this.state.busy) { + return ; + } else { + return null; + } + } + + const StageComponent = getEntryComponentForLoginType(stage); + return ( + + ); + } + + private onAuthStageFailed = (e: Error): void => { + this.props.onAuthFinished(false, e); + }; + + private setEmailSid = (sid: string): void => { + this.authLogic.setEmailSid(sid); + }; + + render() { + let error = null; + if (this.state.errorText) { + error = ( +
+ { this.state.errorText } +
+ ); + } + + return ( +
+
+ { this.renderCurrentStage() } + { error } +
+
+ ); + } +} diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index e002eb5717..a032414eb0 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react'; +import React, { ChangeEvent, ComponentClass, createRef, FormEvent, MouseEvent, RefObject } from 'react'; import classNames from 'classnames'; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { AuthType, IAuthDict, IInputs, IStageStatus } from 'matrix-js-sdk/src/interactive-auth'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; @@ -73,33 +74,6 @@ import { LocalisedPolicy, Policies } from '../../../Terms'; * focus: set the input focus appropriately in the form. */ -enum AuthType { - Password = "m.login.password", - Recaptcha = "m.login.recaptcha", - Terms = "m.login.terms", - Email = "m.login.email.identity", - Msisdn = "m.login.msisdn", - Sso = "m.login.sso", - SsoUnstable = "org.matrix.login.sso", -} - -/* eslint-disable camelcase */ -interface IAuthDict { - type?: AuthType; - // TODO: Remove `user` once servers support proper UIA - // See https://github.com/vector-im/element-web/issues/10312 - user?: string; - identifier?: any; - password?: string; - response?: string; - // TODO: Remove `threepid_creds` once servers support proper UIA - // See https://github.com/vector-im/element-web/issues/10312 - // See https://github.com/matrix-org/matrix-doc/issues/2220 - threepid_creds?: any; - threepidCreds?: any; -} -/* eslint-enable camelcase */ - export const DEFAULT_PHASE = 0; interface IAuthEntryProps { @@ -837,7 +811,26 @@ export class FallbackAuthEntry extends React.Component { } } -export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component { +export interface IStageComponentProps extends IAuthEntryProps { + clientSecret?: string; + stageParams?: Record; + inputs?: IInputs; + stageState?: IStageStatus; + showContinue?: boolean; + continueText?: string; + continueKind?: string; + fail?(e: Error): void; + setEmailSid?(sid: string): void; + onCancel?(): void; +} + +export interface IStageComponent extends React.ComponentClass> { + tryContinue?(): void; + attemptFailed?(): void; + focus?(): void; +} + +export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent { switch (loginType) { case AuthType.Password: return PasswordAuthEntry; diff --git a/src/components/views/dialogs/DeactivateAccountDialog.tsx b/src/components/views/dialogs/DeactivateAccountDialog.tsx index 6df6056670..d30f90d111 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.tsx +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import { AuthType, IAuthData } from 'matrix-js-sdk/src/interactive-auth'; import * as sdk from '../../../index'; import Analytics from '../../../Analytics'; @@ -65,7 +66,7 @@ export default class DeactivateAccountDialog extends React.Component { + private onStagePhaseChange = (stage: AuthType, phase: string): void => { const dialogAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { body: _t("Confirm your account deactivation by using Single Sign On to prove your identity."), @@ -115,7 +116,10 @@ export default class DeactivateAccountDialog extends React.Component { + private onUIAuthComplete = (auth: IAuthData): void => { + // XXX: this should be returning a promise to maintain the state inside the state machine correct + // but given that a deactivation is followed by a local logout and all object instances being thrown away + // this isn't done. MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase).then(r => { // Deactivation worked - logout & close this dialog Analytics.trackEvent('Account', 'Deactivate Account'); @@ -182,7 +186,7 @@ export default class DeactivateAccountDialog extends React.Component Date: Thu, 15 Jul 2021 10:09:24 +0100 Subject: [PATCH 08/31] delint and improve ts --- src/components/views/rooms/Autocomplete.tsx | 28 +++++++++---------- .../views/rooms/BasicMessageComposer.tsx | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index 06386c61b4..34909baef1 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -85,7 +85,7 @@ export default class Autocomplete extends React.PureComponent { 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); @@ -103,7 +103,7 @@ export default class Autocomplete extends React.PureComponent { this.autocompleter.destroy(); } - complete(query: string, selection: ISelectionRange) { + private complete(query: string, selection: ISelectionRange): Promise { this.queryRequested = query; if (this.debounceCompletionsRequest) { clearTimeout(this.debounceCompletionsRequest); @@ -134,7 +134,7 @@ export default class Autocomplete extends React.PureComponent { }); } - processQuery(query: string, selection: ISelectionRange) { + private processQuery(query: string, selection: ISelectionRange): Promise { return this.autocompleter.getCompletions( query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES, ).then((completions) => { @@ -146,7 +146,7 @@ export default class Autocomplete extends React.PureComponent { }); } - processCompletions(completions: IProviderCompletions[]) { + private processCompletions(completions: IProviderCompletions[]): void { const completionList = flatMap(completions, (provider) => provider.completions); // Reset selection when completion list becomes empty. @@ -186,16 +186,16 @@ export default class Autocomplete extends React.PureComponent { }); } - 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 @@ -204,7 +204,7 @@ export default class Autocomplete extends React.PureComponent { 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 @@ -217,7 +217,7 @@ export default class Autocomplete extends React.PureComponent { this.hide(); } - hide = () => { + private hide = (): void => { this.setState({ hide: true, selectionOffset: 1, @@ -226,7 +226,7 @@ export default class Autocomplete extends React.PureComponent { }); }; - forceComplete() { + public forceComplete(): Promise { return new Promise((resolve) => { this.setState({ forceComplete: true, @@ -239,11 +239,11 @@ export default class Autocomplete extends React.PureComponent { }); } - onConfirmCompletion = () => { + public onConfirmCompletion = (): void => { this.onCompletionClicked(this.state.selectionOffset); - } + }; - onCompletionClicked = (selectionOffset: number): boolean => { + private onCompletionClicked = (selectionOffset: number): boolean => { const count = this.countCompletions(); if (count === 0 || selectionOffset < 1 || selectionOffset > count) { return false; @@ -255,7 +255,7 @@ export default class Autocomplete extends React.PureComponent { return true; }; - setSelection(selectionOffset: number) { + private setSelection(selectionOffset: number): void { this.setState({ selectionOffset, hide: false }); if (this.props.onSelectionChange) { this.props.onSelectionChange(selectionOffset - 1); diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index fb3df6cd78..f87c735b6f 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -552,7 +552,7 @@ export default class BasicMessageEditor extends React.Component model.autoComplete.close(); } } else { - this.setState({showVisualBell: true}); + this.setState({ showVisualBell: true }); } } catch (err) { console.error(err); From 9e4f5719a4e2909e80f5e5774aedb94f790ec8c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 5 Aug 2021 11:47:58 +0200 Subject: [PATCH 09/31] Handle narrow layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/views/messages/_CallEvent.scss | 266 +++++++++++--------- src/components/views/messages/CallEvent.tsx | 95 ++++--- 2 files changed, 218 insertions(+), 143 deletions(-) diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss index 0c1b41ca38..5aaaa292d1 100644 --- a/res/css/views/messages/_CallEvent.scss +++ b/res/css/views/messages/_CallEvent.scss @@ -14,126 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_CallEvent { +.mx_CallEvent_wrapper { display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; + justify-content: center; + width: 100%; - background-color: $dark-panel-bg-color; - border-radius: 8px; - margin: 10px auto; - width: 75%; - box-sizing: border-box; - height: 60px; - - &.mx_CallEvent_voice { - .mx_CallEvent_type_icon::before, - .mx_CallEvent_content_button_callBack span::before, - .mx_CallEvent_content_button_answer span::before { - mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); - } - } - - &.mx_CallEvent_video { - .mx_CallEvent_type_icon::before, - .mx_CallEvent_content_button_callBack span::before, - .mx_CallEvent_content_button_answer span::before { - mask-image: url('$(res)/img/element-icons/call/video-call.svg'); - } - } - - &.mx_CallEvent_voice.mx_CallEvent_missed .mx_CallEvent_type_icon::before { - mask-image: url('$(res)/img/voip/missed-voice.svg'); - } - - &.mx_CallEvent_video.mx_CallEvent_missed .mx_CallEvent_type_icon::before { - mask-image: url('$(res)/img/voip/missed-video.svg'); - } - - .mx_CallEvent_info { + .mx_CallEvent { + position: relative; display: flex; flex-direction: row; align-items: center; - margin-left: 12px; + justify-content: space-between; - .mx_CallEvent_info_basic { - display: flex; - flex-direction: column; - margin-left: 10px; // To match mx_CallEvent - - .mx_CallEvent_sender { - font-weight: 600; - font-size: 1.5rem; - line-height: 1.8rem; - margin-bottom: 3px; - } - - .mx_CallEvent_type { - font-weight: 400; - color: $secondary-fg-color; - font-size: 1.2rem; - line-height: $font-13px; - display: flex; - align-items: center; - - .mx_CallEvent_type_icon { - height: 13px; - width: 13px; - margin-right: 5px; - - &::before { - content: ''; - position: absolute; - height: 13px; - width: 13px; - background-color: $tertiary-fg-color; - mask-repeat: no-repeat; - mask-size: contain; - } - } - } - } - } - - .mx_CallEvent_content { - display: flex; - flex-direction: row; - align-items: center; - color: $secondary-fg-color; - margin-right: 16px; - - .mx_CallEvent_content_button { - height: 24px; - padding: 0px 12px; - margin-left: 8px; - - span { - padding: 8px 0; - display: flex; - align-items: center; - - &::before { - content: ''; - display: inline-block; - background-color: $button-fg-color; - mask-position: center; - mask-repeat: no-repeat; - mask-size: 16px; - width: 16px; - height: 16px; - margin-right: 8px; - } - } - } - - .mx_CallEvent_content_button_reject span::before { - mask-image: url('$(res)/img/element-icons/call/hangup.svg'); - } - - .mx_CallEvent_content_tooltip { - margin-right: 5px; - } + background-color: $dark-panel-bg-color; + border-radius: 8px; + width: 75%; + box-sizing: border-box; + height: 60px; .mx_CallEvent_iconButton { display: inline-flex; @@ -158,5 +55,146 @@ limitations under the License. .mx_CallEvent_unSilence::before { mask-image: url('$(res)/img/voip/un-silence.svg'); } + + &.mx_CallEvent_voice { + .mx_CallEvent_type_icon::before, + .mx_CallEvent_content_button_callBack span::before, + .mx_CallEvent_content_button_answer span::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); + } + } + + &.mx_CallEvent_video { + .mx_CallEvent_type_icon::before, + .mx_CallEvent_content_button_callBack span::before, + .mx_CallEvent_content_button_answer span::before { + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + } + + &.mx_CallEvent_voice.mx_CallEvent_missed .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/missed-voice.svg'); + } + + &.mx_CallEvent_video.mx_CallEvent_missed .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/missed-video.svg'); + } + + .mx_CallEvent_info { + display: flex; + flex-direction: row; + align-items: center; + margin-left: 12px; + + .mx_CallEvent_info_basic { + display: flex; + flex-direction: column; + margin-left: 10px; // To match mx_CallEvent + + .mx_CallEvent_sender { + font-weight: 600; + font-size: 1.5rem; + line-height: 1.8rem; + margin-bottom: 3px; + } + + .mx_CallEvent_type { + font-weight: 400; + color: $secondary-fg-color; + font-size: 1.2rem; + line-height: $font-13px; + display: flex; + align-items: center; + + .mx_CallEvent_type_icon { + height: 13px; + width: 13px; + margin-right: 5px; + + &::before { + content: ''; + position: absolute; + height: 13px; + width: 13px; + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + } + } + } + } + } + + .mx_CallEvent_content { + display: flex; + flex-direction: row; + align-items: center; + color: $secondary-fg-color; + margin-right: 16px; + + .mx_CallEvent_content_button { + height: 24px; + padding: 0px 12px; + margin-left: 8px; + + span { + padding: 8px 0; + display: flex; + align-items: center; + + &::before { + content: ''; + display: inline-block; + background-color: $button-fg-color; + mask-position: center; + mask-repeat: no-repeat; + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 8px; + } + } + } + + .mx_CallEvent_content_button_reject span::before { + mask-image: url('$(res)/img/element-icons/call/hangup.svg'); + } + + .mx_CallEvent_content_tooltip { + margin-right: 5px; + } + } + + &.mx_CallEvent_narrow { + height: unset; + width: 290px; + flex-direction: column; + align-items: unset; + gap: 16px; + + .mx_CallEvent_iconButton { + position: absolute; + margin-right: 0; + top: 12px; + right: 12px; + height: 16px; + width: 16px; + display: flex; + } + + .mx_CallEvent_info { + margin-top: 12px; + margin-right: 12px; + + .mx_CallEvent_sender { + margin-bottom: 8px; + } + } + + .mx_CallEvent_content { + margin-left: 54px; // mx_CallEvent margin (12px) + avatar (32px) + mx_CallEvent_info_basic margin (10px) + margin-bottom: 16px; + } + } } } diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index a204907caa..822d99d5a6 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -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 { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t, _td } from '../../../languageHandler'; @@ -26,6 +26,8 @@ import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip'; import classNames from 'classnames'; import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; +const MAX_NON_NARROW_WIDTH = 400 / 70 * 100; + interface IProps { mxEvent: MatrixEvent; callEventGrouper: CallEventGrouper; @@ -34,6 +36,7 @@ interface IProps { interface IState { callState: CallState | CustomCallState; silenced: boolean; + narrow: boolean; } const TEXTUAL_STATES: Map = new Map([ @@ -41,26 +44,42 @@ const TEXTUAL_STATES: Map = new Map([ [CallState.Connecting, _td("Connecting")], ]); -export default class CallEvent extends React.Component { +export default class CallEvent extends React.PureComponent { + private wrapperElement = createRef(); + private resizeObserver: ResizeObserver; + constructor(props: IProps) { super(props); this.state = { callState: this.props.callEventGrouper.state, silenced: false, + narrow: false, }; } componentDidMount() { this.props.callEventGrouper.addListener(CallEventGrouperEvent.StateChanged, this.onStateChanged); this.props.callEventGrouper.addListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged); + + this.resizeObserver = new ResizeObserver(this.resizeObserverCallback); + this.resizeObserver.observe(this.wrapperElement.current); } componentWillUnmount() { this.props.callEventGrouper.removeListener(CallEventGrouperEvent.StateChanged, this.onStateChanged); this.props.callEventGrouper.removeListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged); + + this.resizeObserver.disconnect(); } + private resizeObserverCallback = (entries: ResizeObserverEntry[]): void => { + const wrapperElementEntry = entries.find((entry) => entry.target === this.wrapperElement.current); + if (!wrapperElementEntry) return; + + this.setState({ narrow: wrapperElementEntry.contentRect.width < MAX_NON_NARROW_WIDTH }); + }; + private onSilencedChanged = (newState) => { this.setState({ silenced: newState }); }; @@ -81,21 +100,32 @@ export default class CallEvent extends React.Component { ); } + private renderSilenceIcon(): JSX.Element { + const silenceClass = classNames({ + "mx_CallEvent_iconButton": true, + "mx_CallEvent_unSilence": this.state.silenced, + "mx_CallEvent_silence": !this.state.silenced, + }); + + return ( + + ); + } + private renderContent(state: CallState | CustomCallState): JSX.Element { if (state === CallState.Ringing) { - const silenceClass = classNames({ - "mx_CallEvent_iconButton": true, - "mx_CallEvent_unSilence": this.state.silenced, - "mx_CallEvent_silence": !this.state.silenced, - }); + let silenceIcon; + if (!this.state.narrow) { + silenceIcon = this.renderSilenceIcon(); + } return (
- + { silenceIcon } { const callState = this.state.callState; const hangupReason = this.props.callEventGrouper.hangupReason; const content = this.renderContent(callState); - const className = classNames({ - mx_CallEvent: true, + const className = classNames("mx_CallEvent", { mx_CallEvent_voice: isVoice, mx_CallEvent_video: !isVoice, + mx_CallEvent_narrow: this.state.narrow, mx_CallEvent_missed: ( callState === CustomCallState.Missed || (callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout) ), }); + let silenceIcon; + if (this.state.narrow && this.state.callState === CallState.Ringing) { + silenceIcon = this.renderSilenceIcon(); + } return ( -
-
- -
-
- { sender } -
-
-
- { callType } +
+
+ { silenceIcon } +
+ +
+
+ { sender } +
+
+
+ { callType } +
+ { content }
- { content }
); } From 5fa6ef83d207c7d1abb39780ecb510d3b95104d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 5 Aug 2021 12:35:44 +0200 Subject: [PATCH 10/31] Fix button alighnment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/views/messages/_CallEvent.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss index 5aaaa292d1..22942fdd2c 100644 --- a/res/css/views/messages/_CallEvent.scss +++ b/res/css/views/messages/_CallEvent.scss @@ -131,11 +131,11 @@ limitations under the License. align-items: center; color: $secondary-fg-color; margin-right: 16px; + gap: 8px; .mx_CallEvent_content_button { height: 24px; padding: 0px 12px; - margin-left: 8px; span { padding: 8px 0; From efeae3a84cfafb26790ee40d8a12cde2dadc8188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 5 Aug 2021 13:39:39 +0200 Subject: [PATCH 11/31] Top align the avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/views/messages/_CallEvent.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss index 22942fdd2c..dde5856484 100644 --- a/res/css/views/messages/_CallEvent.scss +++ b/res/css/views/messages/_CallEvent.scss @@ -183,6 +183,7 @@ limitations under the License. } .mx_CallEvent_info { + align-items: unset; margin-top: 12px; margin-right: 12px; From d90321d8133bc71be3b316922bdd0386176e5a67 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 10 Aug 2021 11:03:03 +0100 Subject: [PATCH 12/31] Iterate PR, merge types with @types/PushRules --- src/CallHandler.tsx | 4 ++-- src/components/structures/InteractiveAuth.tsx | 16 ++++++++-------- .../auth/InteractiveAuthEntryComponents.tsx | 2 +- .../controllers/NotificationControllers.ts | 5 +++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 41571666c3..f2142f56f4 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -86,7 +86,7 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/ import EventEmitter from 'events'; import SdkConfig from './SdkConfig'; import { ensureDMExists, findDMForUser } from './createRoom'; -import { IPushRule, RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules"; +import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules"; import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore'; import { getIncomingCallToastKey } from './toasts/IncomingCallToast'; @@ -484,7 +484,7 @@ export default class CallHandler extends EventEmitter { switch (newState) { case CallState.Ringing: { const incomingCallPushRule = ( - new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) as IPushRule + new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) ); const pushRuleEnabled = incomingCallPushRule?.enabled; const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => ( diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx index 017e34184f..cea756adc0 100644 --- a/src/components/structures/InteractiveAuth.tsx +++ b/src/components/structures/InteractiveAuth.tsx @@ -35,7 +35,7 @@ interface IProps { // matrix client to use for UI auth requests matrixClient: MatrixClient; // response from initial request. If not supplied, will do a request on mount. - authData: IAuthData; + authData?: IAuthData; // Inputs provided by the user to the auth process // and used by various stages. As passed to js-sdk // interactive-auth @@ -72,11 +72,11 @@ interface IProps { extra?: { emailSid?: string, clientSecret?: string }, ): void; // As js-sdk interactive-auth - requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>, + requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>; // Called when the stage changes, or the stage's phase changes. First // argument is the stage, second is the phase. Some stages do not have // phases and will be counted as 0 (numeric). - onStagePhaseChange?(stage: string, phase: string | number): void, + onStagePhaseChange?(stage: string, phase: string | number): void; } interface IState { @@ -91,7 +91,7 @@ interface IState { @replaceableComponent("structures.InteractiveAuthComponent") export default class InteractiveAuthComponent extends React.Component { private readonly authLogic: InteractiveAuth; - private readonly _intervalId: NodeJS.Timeout = null; + private readonly intervalId: number = null; private readonly stageComponent = createRef(); private unmounted = false; @@ -121,14 +121,14 @@ export default class InteractiveAuthComponent extends React.Component { + this.intervalId = setInterval(() => { this.authLogic.poll(); }, 2000); } } // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount() { // eslint-disable-line camelcase + UNSAFE_componentWillMount() { // eslint-disable-line @typescript-eslint/naming-convention, camelcase this.authLogic.attemptAuth().then((result) => { const extra = { emailSid: this.authLogic.getEmailSid(), @@ -152,8 +152,8 @@ export default class InteractiveAuthComponent extends React.Component Date: Wed, 11 Aug 2021 21:45:49 +0100 Subject: [PATCH 13/31] Iterate PR based on feedback --- src/components/structures/InteractiveAuth.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx index cea756adc0..869cd29cba 100644 --- a/src/components/structures/InteractiveAuth.tsx +++ b/src/components/structures/InteractiveAuth.tsx @@ -235,7 +235,7 @@ export default class InteractiveAuthComponent extends React.Component Date: Wed, 11 Aug 2021 21:50:26 +0100 Subject: [PATCH 14/31] add comment --- src/components/views/dialogs/DeactivateAccountDialog.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/views/dialogs/DeactivateAccountDialog.tsx b/src/components/views/dialogs/DeactivateAccountDialog.tsx index bbed866464..6548bd78fc 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.tsx +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -184,6 +184,8 @@ export default class DeactivateAccountDialog extends React.Component Date: Thu, 12 Aug 2021 08:11:11 +0200 Subject: [PATCH 15/31] Use AccessibleTooltipButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/voip/CallView/CallViewButtons.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/views/voip/CallView/CallViewButtons.tsx b/src/components/views/voip/CallView/CallViewButtons.tsx index 8c48bd767d..466311f421 100644 --- a/src/components/views/voip/CallView/CallViewButtons.tsx +++ b/src/components/views/voip/CallView/CallViewButtons.tsx @@ -21,7 +21,6 @@ import classNames from "classnames"; import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton"; import CallContextMenu from "../../context_menus/CallContextMenu"; import DialpadContextMenu from "../../context_menus/DialpadContextMenu"; -import AccessibleButton from "../../elements/AccessibleButton"; import { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { Alignment } from "../../elements/Tooltip"; import { @@ -211,10 +210,12 @@ export default class CallViewButtons extends React.Component { let sidebarButton; if (this.props.buttonsVisibility.sidebar) { sidebarButton = ( - ); } From f53eb4eeedc1e7c449d92956a1ecebdbd4d4affa Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 12 Aug 2021 11:27:34 +0100 Subject: [PATCH 16/31] Fix tab trapping behaviour --- src/components/views/rooms/BasicMessageComposer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 68dd874037..48f2e2a39b 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -462,7 +462,7 @@ export default class BasicMessageEditor extends React.Component } const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event); - if (model.autoComplete && model.autoComplete.hasCompletions()) { + if (model.autoComplete?.hasCompletions()) { const autoComplete = model.autoComplete; switch (autocompleteAction) { case AutocompleteAction.ForceComplete: @@ -485,7 +485,7 @@ export default class BasicMessageEditor extends React.Component default: return; // don't preventDefault on anything else } - } else if (autocompleteAction === AutocompleteAction.ForceComplete) { + } else if (autocompleteAction === AutocompleteAction.ForceComplete && !this.state.showVisualBell) { // there is no current autocomplete window, try to open it this.tabCompleteName(); handled = true; From aca073f5dae5b799083346186d04da86a349da4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 12 Aug 2021 16:16:13 +0200 Subject: [PATCH 17/31] $system-... -> $system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/themes/dark/css/_dark.scss | 12 ++++++------ res/themes/legacy-light/css/_legacy-light.scss | 10 +++++----- res/themes/light/css/_light.scss | 14 +++++++------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 9a85c9d2b0..060e56c7a0 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -7,9 +7,9 @@ $secondary-content: #A9B2BC; $tertiary-content: #8E99A4; $quaternary-content: #6F7882; $quinary-content: #394049; -$system-dark: #21262C; +$system: #21262C; $background: #15191E; -$panels: rgba($system-dark, 0.9); +$panels: rgba($system, 0.9); $panel-base: #8D97A5; // This color is not intended for use in the app $panel-selected: rgba($panel-base, 0.3); $panel-hover: rgba($panel-base, 0.1); @@ -67,7 +67,7 @@ $inverted-bg-color: $base-color; $selected-color: $room-highlight-color; // selected for hoverover & selected event tiles -$event-selected-color: $system-dark; +$event-selected-color: $system; // used for the hairline dividers in RoomView $primary-hairline-color: transparent; @@ -111,7 +111,7 @@ $lightbox-background-bg-color: #000; $lightbox-background-bg-opacity: 0.85; $settings-grey-fg-color: #a2a2a2; -$settings-profile-placeholder-bg-color: $system-dark; +$settings-profile-placeholder-bg-color: $system; $settings-profile-overlay-placeholder-fg-color: #454545; $settings-profile-button-bg-color: #e7e7e7; $settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; @@ -194,7 +194,7 @@ $button-link-bg-color: transparent; $togglesw-off-color: $room-highlight-color; $progressbar-fg-color: $accent-color; -$progressbar-bg-color: $system-dark; +$progressbar-bg-color: $system; $visual-bell-bg-color: #800; @@ -229,7 +229,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color; $message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-bg-color: $quinary-content; $message-body-panel-icon-fg-color: $secondary-fg-color; -$message-body-panel-icon-bg-color: $system-dark; // "System Dark" +$message-body-panel-icon-bg-color: $system; // "System Dark" $voice-record-stop-border-color: $quaternary-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 1a63c9bd07..0edf3b64f2 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -13,7 +13,7 @@ $font-family: 'Nunito', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial $monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji'; // Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0 -$system-light: #F4F6FA; +$system: #F4F6FA; // unified palette // try to use these colors when possible @@ -181,8 +181,8 @@ $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #91a1c0; $header-divider-color: #91a1c0; -$toast-bg-color: $system-light; -$voipcall-plinth-color: $system-light; +$toast-bg-color: $system; +$voipcall-plinth-color: $system; // ******************** @@ -334,7 +334,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color; $message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-bg-color: #E3E8F0; $message-body-panel-icon-fg-color: $secondary-fg-color; -$message-body-panel-icon-bg-color: $system-light; +$message-body-panel-icon-bg-color: $system; // See non-legacy _light for variable information $voice-record-stop-symbol-color: #ff4b55; @@ -352,7 +352,7 @@ $composer-shadow-color: tranparent; // Bubble tiles $eventbubble-self-bg: #F0FBF8; -$eventbubble-others-bg: $system-light; +$eventbubble-others-bg: $system; $eventbubble-bg-hover: #FAFBFD; $eventbubble-avatar-outline: #fff; $eventbubble-reply-color: #C1C6CD; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 0f7ede4ec9..61ca3709c2 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -21,9 +21,9 @@ $secondary-content: #737D8C; $tertiary-content: #8D97A5; $quaternary-content: #c1c6cd; $quinary-content: #E3E8F0; -$system-light: #F4F6FA; +$system: #F4F6FA; $background: #ffffff; -$panels: rgba($system-light, 0.9); +$panels: rgba($system, 0.9); $panel-selected: rgba($tertiary-content, 0.3); $panel-hover: rgba($tertiary-content, 0.1); $panel-actions: rgba($tertiary-content, 0.2); @@ -157,7 +157,7 @@ $blockquote-bar-color: #ddd; $blockquote-fg-color: #777; $settings-grey-fg-color: #a2a2a2; -$settings-profile-placeholder-bg-color: $system-light; +$settings-profile-placeholder-bg-color: $system; $settings-profile-overlay-placeholder-fg-color: #2e2f32; $settings-profile-button-bg-color: #e7e7e7; $settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color; @@ -186,8 +186,8 @@ $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #91A1C0; $header-divider-color: #91A1C0; -$toast-bg-color: $system-light; -$voipcall-plinth-color: $system-light; +$toast-bg-color: $system; +$voipcall-plinth-color: $system; // ******************** @@ -346,7 +346,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color; $message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-bg-color: $quinary-content; $message-body-panel-icon-fg-color: $secondary-fg-color; -$message-body-panel-icon-bg-color: $system-light; +$message-body-panel-icon-bg-color: $system; // These two don't change between themes. They are the $warning-color, but we don't // want custom themes to affect them by accident. @@ -370,7 +370,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.04); // Bubble tiles $eventbubble-self-bg: #F0FBF8; -$eventbubble-others-bg: $system-light; +$eventbubble-others-bg: $system; $eventbubble-bg-hover: #FAFBFD; $eventbubble-avatar-outline: $primary-bg-color; $eventbubble-reply-color: $quaternary-content; From 7ce88c9966dedb79fa9ccdf1ba4b6dcb901a0fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 12 Aug 2021 16:20:30 +0200 Subject: [PATCH 18/31] $toast-bg-color -> $system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/structures/_ToastContainer.scss | 4 ++-- res/css/views/voip/_CallView.scss | 2 +- res/themes/dark/css/_dark.scss | 2 -- res/themes/legacy-dark/css/_legacy-dark.scss | 6 +++--- res/themes/legacy-light/css/_legacy-light.scss | 1 - res/themes/light/css/_light.scss | 1 - 6 files changed, 6 insertions(+), 10 deletions(-) diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 2c3f1c705c..5cd938f1ce 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -28,7 +28,7 @@ limitations under the License. margin: 0 4px; grid-row: 2 / 4; grid-column: 1; - background-color: $toast-bg-color; + background-color: $system; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); border-radius: 8px; } @@ -37,7 +37,7 @@ limitations under the License. grid-row: 1 / 3; grid-column: 1; color: $primary-fg-color; - background-color: $toast-bg-color; + background-color: $system; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5); border-radius: 8px; overflow: hidden; diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 498dd8e096..df961d852b 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -39,7 +39,7 @@ limitations under the License. .mx_CallView_pip { width: 320px; padding-bottom: 8px; - background-color: $toast-bg-color; + background-color: $system; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); border-radius: 8px; diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 060e56c7a0..8c305b9828 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -132,8 +132,6 @@ $eventtile-meta-color: $roomtopic-color; $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; -$toast-bg-color: $quinary-content; - // ******************** $theme-button-bg-color: #e3e8f0; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index b9429318ac..3e3412c6c1 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -1,3 +1,6 @@ +// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741 +$system: #21262C; + // unified palette // try to use these colors when possible $bg-color: #181b21; @@ -111,9 +114,6 @@ $eventtile-meta-color: $roomtopic-color; $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; -$quinary-content-color: #394049; -$toast-bg-color: $quinary-content-color; - // ******************** $theme-button-bg-color: #e3e8f0; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 0edf3b64f2..3f722bcb30 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -181,7 +181,6 @@ $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #91a1c0; $header-divider-color: #91a1c0; -$toast-bg-color: $system; $voipcall-plinth-color: $system; // ******************** diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 61ca3709c2..e64fe12d3b 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -186,7 +186,6 @@ $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #91A1C0; $header-divider-color: #91A1C0; -$toast-bg-color: $system; $voipcall-plinth-color: $system; // ******************** From adce43600d89d5dd809acbe38906535586c5456f Mon Sep 17 00:00:00 2001 From: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com> Date: Thu, 12 Aug 2021 15:34:33 +0100 Subject: [PATCH 19/31] Update link to matrix-js-sdk CONTRIBUTING file (#6557) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7c8c8b1c5..f0ca3eb8a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ Contributing code to The React SDK ================================== -matrix-react-sdk follows the same pattern as https://github.com/matrix-org/matrix-js-sdk/blob/master/CONTRIBUTING.rst +matrix-react-sdk follows the same pattern as https://github.com/matrix-org/matrix-js-sdk/blob/master/CONTRIBUTING.md From 657dcaf9895a62629e57d0673b2dca3ec075c813 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 12 Aug 2021 18:36:57 +0100 Subject: [PATCH 20/31] Fix video call persisting when widget removed Fixes https://github.com/vector-im/element-web/issues/15703 Type: defect --- src/stores/WidgetStore.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 732428107f..f1a0e165e8 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -137,6 +137,26 @@ export default class WidgetStore extends AsyncStoreWithClient { if (edited && !this.roomMap.has(room.roomId)) { this.roomMap.set(room.roomId, roomInfo); } + + // If a persistent widget is active, check to see if it's just been removed. + // If it has, it needs to destroyed otherwise unmounting the node won't kill it + const persistentWidgetId = ActiveWidgetStore.getPersistentWidgetId(); + if (persistentWidgetId) { + if ( + ActiveWidgetStore.getRoomId(persistentWidgetId) === room.roomId && + !roomInfo.widgets.some(w => w.id === persistentWidgetId) + ) { + console.log(`Persistent widget ${persistentWidgetId} removed from room ${room.roomId}: destroying.`); + ActiveWidgetStore.destroyPersistentWidget(persistentWidgetId); + } + } + + /*if ( + oldWidgetIds.includes(ActiveWidgetStore.getPersistentWidgetId()) && + !roomInfo.widgets.map(w => w.id).includes(ActiveWidgetStore.getPersistentWidgetId())) { + ActiveWidgetStore.destroyPersistentWidget(ActiveWidgetStore.getPersistentWidgetId()); + }*/ + this.emit(room.roomId); } From 3528d87f30f686c5dfc84d902df55bbd482f7935 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 12 Aug 2021 18:39:11 +0100 Subject: [PATCH 21/31] Remove old version --- src/stores/WidgetStore.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index f1a0e165e8..e9820eee06 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -151,12 +151,6 @@ export default class WidgetStore extends AsyncStoreWithClient { } } - /*if ( - oldWidgetIds.includes(ActiveWidgetStore.getPersistentWidgetId()) && - !roomInfo.widgets.map(w => w.id).includes(ActiveWidgetStore.getPersistentWidgetId())) { - ActiveWidgetStore.destroyPersistentWidget(ActiveWidgetStore.getPersistentWidgetId()); - }*/ - this.emit(room.roomId); } From 1ad35b15646611dcedcb09f3c8c8f30da39af6e0 Mon Sep 17 00:00:00 2001 From: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com> Date: Thu, 12 Aug 2021 18:58:06 +0100 Subject: [PATCH 22/31] Keep number field in focus when pressing dialpad buttons (#6520) --- .../context_menus/DialpadContextMenu.tsx | 17 ++++++++++--- src/components/views/dialogs/InviteDialog.tsx | 23 ++++++++++++++--- .../views/elements/DialPadBackspaceButton.tsx | 4 +-- src/components/views/voip/DialPad.tsx | 14 +++++------ src/components/views/voip/DialPadModal.tsx | 25 ++++++++++++++++--- 5 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx index 0bb96f9397..01c7c6c1d8 100644 --- a/src/components/views/context_menus/DialpadContextMenu.tsx +++ b/src/components/views/context_menus/DialpadContextMenu.tsx @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import AccessibleButton from "../elements/AccessibleButton"; +import * as React from "react"; +import { createRef } from "react"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import Field from "../elements/Field"; @@ -32,6 +33,8 @@ interface IState { @replaceableComponent("views.context_menus.DialpadContextMenu") export default class DialpadContextMenu extends React.Component { + private numberEntryFieldRef: React.RefObject = createRef(); + constructor(props) { super(props); @@ -40,9 +43,16 @@ export default class DialpadContextMenu extends React.Component }; } - onDigitPress = (digit) => { + onDigitPress = (digit: string, ev: ButtonEvent) => { this.props.call.sendDtmfDigit(digit); this.setState({ value: this.state.value + digit }); + + // Keep the number field focused so that keyboard entry is still available + // However, don't focus if this wasn't the result of directly clicking on the button, + // i.e someone using keyboard navigation. + if (ev.type === "click") { + this.numberEntryFieldRef.current?.focus(); + } }; onCancelClick = () => { @@ -68,6 +78,7 @@ export default class DialpadContextMenu extends React.Component
void; private debounceTimer: number = null; // actually number because we're in the browser private editorRef = createRef(); + private numberEntryFieldRef: React.RefObject = createRef(); private unmounted = false; constructor(props) { @@ -1283,13 +1284,27 @@ export default class InviteDialog extends React.PureComponent { + private onDigitPress = (digit: string, ev: ButtonEvent) => { this.setState({ dialPadValue: this.state.dialPadValue + digit }); + + // Keep the number field focused so that keyboard entry is still available + // However, don't focus if this wasn't the result of directly clicking on the button, + // i.e someone using keyboard navigation. + if (ev.type === "click") { + this.numberEntryFieldRef.current?.focus(); + } }; - private onDeletePress = () => { + private onDeletePress = (ev: ButtonEvent) => { if (this.state.dialPadValue.length === 0) return; this.setState({ dialPadValue: this.state.dialPadValue.slice(0, -1) }); + + // Keep the number field focused so that keyboard entry is still available + // However, don't focus if this wasn't the result of directly clicking on the button, + // i.e someone using keyboard navigation. + if (ev.type === "click") { + this.numberEntryFieldRef.current?.focus(); + } }; private onTabChange = (tabId: TabId) => { @@ -1543,6 +1558,7 @@ export default class InviteDialog extends React.PureComponent; } else { dialPadField = void; + onBackspacePress: (ev: ButtonEvent) => void; } export default class DialPadBackspaceButton extends React.PureComponent { diff --git a/src/components/views/voip/DialPad.tsx b/src/components/views/voip/DialPad.tsx index 3b4a29b3f9..46584e0870 100644 --- a/src/components/views/voip/DialPad.tsx +++ b/src/components/views/voip/DialPad.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import * as React from "react"; -import AccessibleButton from "../elements/AccessibleButton"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import { replaceableComponent } from "../../../utils/replaceableComponent"; const BUTTONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']; @@ -30,12 +30,12 @@ interface IButtonProps { kind: DialPadButtonKind; digit?: string; digitSubtext?: string; - onButtonPress: (string) => void; + onButtonPress: (digit: string, ev: ButtonEvent) => void; } class DialPadButton extends React.PureComponent { - onClick = () => { - this.props.onButtonPress(this.props.digit); + onClick = (ev: ButtonEvent) => { + this.props.onButtonPress(this.props.digit, ev); }; render() { @@ -54,10 +54,10 @@ class DialPadButton extends React.PureComponent { } interface IProps { - onDigitPress: (string) => void; + onDigitPress: (digit: string, ev: ButtonEvent) => void; hasDial: boolean; - onDeletePress?: (string) => void; - onDialPress?: (string) => void; + onDeletePress?: (ev: ButtonEvent) => void; + onDialPress?: () => void; } @replaceableComponent("views.voip.DialPad") diff --git a/src/components/views/voip/DialPadModal.tsx b/src/components/views/voip/DialPadModal.tsx index a36fc37dff..4d69260565 100644 --- a/src/components/views/voip/DialPadModal.tsx +++ b/src/components/views/voip/DialPadModal.tsx @@ -15,7 +15,8 @@ limitations under the License. */ import * as React from "react"; -import AccessibleButton from "../elements/AccessibleButton"; +import { createRef } from "react"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import Field from "../elements/Field"; import DialPad from './DialPad'; import dis from '../../../dispatcher/dispatcher'; @@ -34,6 +35,8 @@ interface IState { @replaceableComponent("views.voip.DialPadModal") export default class DialpadModal extends React.PureComponent { + private numberEntryFieldRef: React.RefObject = createRef(); + constructor(props) { super(props); this.state = { @@ -54,13 +57,27 @@ export default class DialpadModal extends React.PureComponent { this.onDialPress(); }; - onDigitPress = (digit) => { + onDigitPress = (digit: string, ev: ButtonEvent) => { this.setState({ value: this.state.value + digit }); + + // Keep the number field focused so that keyboard entry is still available. + // However, don't focus if this wasn't the result of directly clicking on the button, + // i.e someone using keyboard navigation. + if (ev.type === "click") { + this.numberEntryFieldRef.current?.focus(); + } }; - onDeletePress = () => { + onDeletePress = (ev: ButtonEvent) => { if (this.state.value.length === 0) return; this.setState({ value: this.state.value.slice(0, -1) }); + + // Keep the number field focused so that keyboard entry is still available + // However, don't focus if this wasn't the result of directly clicking on the button, + // i.e someone using keyboard navigation. + if (ev.type === "click") { + this.numberEntryFieldRef.current?.focus(); + } }; onDialPress = async () => { @@ -82,6 +99,7 @@ export default class DialpadModal extends React.PureComponent { let dialPadField; if (this.state.value.length !== 0) { dialPadField = { />; } else { dialPadField = Date: Fri, 13 Aug 2021 10:35:31 +0200 Subject: [PATCH 23/31] Add a little padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/views/messages/_CallEvent.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss index dde5856484..7320c5a5cb 100644 --- a/res/css/views/messages/_CallEvent.scss +++ b/res/css/views/messages/_CallEvent.scss @@ -31,6 +31,7 @@ limitations under the License. width: 75%; box-sizing: border-box; height: 60px; + margin: 4px 0; .mx_CallEvent_iconButton { display: inline-flex; From c79852a9f0e245a5ee3c1ee0513f50695b8e5ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Fri, 13 Aug 2021 10:59:59 +0200 Subject: [PATCH 24/31] Left align call tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/views/messages/_CallEvent.scss | 3 +-- src/components/views/rooms/EventTile.tsx | 7 +++++-- src/utils/EventUtils.ts | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss index 7320c5a5cb..4bff9c6f52 100644 --- a/res/css/views/messages/_CallEvent.scss +++ b/res/css/views/messages/_CallEvent.scss @@ -16,7 +16,6 @@ limitations under the License. .mx_CallEvent_wrapper { display: flex; - justify-content: center; width: 100%; .mx_CallEvent { @@ -28,7 +27,7 @@ limitations under the License. background-color: $dark-panel-bg-color; border-radius: 8px; - width: 75%; + width: 65%; box-sizing: border-box; height: 60px; margin: 4px 0; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 884d004551..301e33ec42 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -932,8 +932,11 @@ export default class EventTile extends React.Component { } 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) || + this.props.mxEvent.getType() === EventType.CallInvite + ) { + // no avatar or sender profile for continuation messages and call tiles avatarSize = 0; needsSenderProfile = false; } else { diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index e2af1c7464..7aef05c523 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -116,14 +116,14 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent): { (eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) || (eventType === EventType.RoomCreate) || (eventType === EventType.RoomEncryption) || - (eventType === EventType.CallInvite) || (tileHandler === "messages.MJitsiWidgetEvent") ); let isInfoMessage = ( !isBubbleMessage && eventType !== EventType.RoomMessage && eventType !== EventType.Sticker && - eventType !== EventType.RoomCreate + eventType !== EventType.RoomCreate && + eventType !== EventType.CallInvite ); // If we're showing hidden events in the timeline, we should use the From 032d2866a3b67f904202c7614f75ad848de4d840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Fri, 13 Aug 2021 11:19:14 +0200 Subject: [PATCH 25/31] Add "No answer" state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/messages/CallEvent.tsx | 2 +- src/i18n/strings/en_EN.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index 655b1a42c5..2883d6b576 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -175,7 +175,7 @@ export default class CallEvent extends React.PureComponent { } else if (hangupReason === CallErrorCode.InviteTimeout) { return (
- { _t("Missed call") } + { _t("No answer") } { this.renderCallBackButton(_t("Call back")) }
); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c9dbc00a78..33746e3f8b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1887,13 +1887,14 @@ "Connected": "Connected", "Call declined": "Call declined", "Call back": "Call back", - "Missed call": "Missed call", + "No answer": "No answer", "Could not connect media": "Could not connect media", "Connection failed": "Connection failed", "Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone", "An unknown error occurred": "An unknown error occurred", "Unknown failure: %(reason)s)": "Unknown failure: %(reason)s)", "Retry": "Retry", + "Missed call": "Missed call", "The call is in an unknown state!": "The call is in an unknown state!", "Sunday": "Sunday", "Monday": "Monday", From fa204c41045f295fe4fbbf997d8aafe0e5534f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Fri, 13 Aug 2021 11:34:11 +0200 Subject: [PATCH 26/31] Add declined call buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/img/voip/declined-video.svg | 3 +++ res/img/voip/declined-voice.svg | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 res/img/voip/declined-video.svg create mode 100644 res/img/voip/declined-voice.svg diff --git a/res/img/voip/declined-video.svg b/res/img/voip/declined-video.svg new file mode 100644 index 0000000000..509ffa8fd1 --- /dev/null +++ b/res/img/voip/declined-video.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/voip/declined-voice.svg b/res/img/voip/declined-voice.svg new file mode 100644 index 0000000000..78e8d90cdf --- /dev/null +++ b/res/img/voip/declined-voice.svg @@ -0,0 +1,4 @@ + + + + From cda91e44e00116c0e22d5f8357d2724222d7acc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Fri, 13 Aug 2021 11:37:17 +0200 Subject: [PATCH 27/31] Use new call state icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/views/messages/_CallEvent.scss | 10 ++++++++++ src/components/views/messages/CallEvent.tsx | 7 +++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss index 4bff9c6f52..2d9caf1569 100644 --- a/res/css/views/messages/_CallEvent.scss +++ b/res/css/views/messages/_CallEvent.scss @@ -80,6 +80,16 @@ limitations under the License. mask-image: url('$(res)/img/voip/missed-video.svg'); } + &.mx_CallEvent_voice.mx_CallEvent_rejected .mx_CallEvent_type_icon::before, + &.mx_CallEvent_voice.mx_CallEvent_noAnswer .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/declined-voice.svg'); + } + + &.mx_CallEvent_video.mx_CallEvent_rejected .mx_CallEvent_type_icon::before, + &.mx_CallEvent_video.mx_CallEvent_noAnswer .mx_CallEvent_type_icon::before { + mask-image: url('$(res)/img/voip/declined-video.svg'); + } + .mx_CallEvent_info { display: flex; flex-direction: row; diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index 2883d6b576..594b0b7d99 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -249,10 +249,9 @@ export default class CallEvent extends React.PureComponent { mx_CallEvent_voice: isVoice, mx_CallEvent_video: !isVoice, mx_CallEvent_narrow: this.state.narrow, - mx_CallEvent_missed: ( - callState === CustomCallState.Missed || - (callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout) - ), + mx_CallEvent_missed: callState === CustomCallState.Missed, + mx_CallEvent_noAnswer: callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout, + mx_CallEvent_rejected: callState === CallState.Ended && this.props.callEventGrouper.gotRejected, }); let silenceIcon; if (this.state.narrow && this.state.callState === CallState.Ringing) { From 0ee59a17de3aab2f7b8ca4b77e6853deabef8f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Fri, 13 Aug 2021 18:42:55 +0200 Subject: [PATCH 28/31] Fix PiP of held calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- res/css/views/voip/_CallView.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index df961d852b..63ca91267f 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -43,6 +43,7 @@ limitations under the License. box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); border-radius: 8px; + .mx_CallView_video_hold, .mx_CallView_voice { height: 180px; } From e78640572d75498a626f5f89e14f228da43a0585 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 13 Aug 2021 18:07:58 +0100 Subject: [PATCH 29/31] Convert CrossSigningPanel to TS Type: task --- ...sSigningPanel.js => CrossSigningPanel.tsx} | 76 ++++++++++--------- 1 file changed, 39 insertions(+), 37 deletions(-) rename src/components/views/settings/{CrossSigningPanel.js => CrossSigningPanel.tsx} (86%) diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.tsx similarity index 86% rename from src/components/views/settings/CrossSigningPanel.js rename to src/components/views/settings/CrossSigningPanel.tsx index 8b9d68bfa5..3dd9ea0512 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.tsx @@ -25,35 +25,37 @@ import InteractiveAuthDialog from '../dialogs/InteractiveAuthDialog'; import ConfirmDestroyCrossSigningDialog from '../dialogs/security/ConfirmDestroyCrossSigningDialog'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +interface IState { + error?: Error; + crossSigningPublicKeysOnDevice?: boolean; + crossSigningPrivateKeysInStorage?: boolean; + masterPrivateKeyCached?: boolean; + selfSigningPrivateKeyCached?: boolean; + userSigningPrivateKeyCached?: boolean; + homeserverSupportsCrossSigning?: boolean; + crossSigningReady?: boolean; +} + @replaceableComponent("views.settings.CrossSigningPanel") -export default class CrossSigningPanel extends React.PureComponent { +export default class CrossSigningPanel extends React.PureComponent<{}, IState> { + private unmounted = false; + constructor(props) { super(props); - this._unmounted = false; - - this.state = { - error: null, - crossSigningPublicKeysOnDevice: null, - crossSigningPrivateKeysInStorage: null, - masterPrivateKeyCached: null, - selfSigningPrivateKeyCached: null, - userSigningPrivateKeyCached: null, - homeserverSupportsCrossSigning: null, - crossSigningReady: null, - }; + this.state = {}; } - componentDidMount() { + public componentDidMount() { const cli = MatrixClientPeg.get(); cli.on("accountData", this.onAccountData); cli.on("userTrustStatusChanged", this.onStatusChanged); cli.on("crossSigning.keysChanged", this.onStatusChanged); - this._getUpdatedStatus(); + this.getUpdatedStatus(); } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount() { + this.unmounted = true; const cli = MatrixClientPeg.get(); if (!cli) return; cli.removeListener("accountData", this.onAccountData); @@ -61,28 +63,28 @@ export default class CrossSigningPanel extends React.PureComponent { cli.removeListener("crossSigning.keysChanged", this.onStatusChanged); } - onAccountData = (event) => { + private onAccountData = (event): void => { const type = event.getType(); if (type.startsWith("m.cross_signing") || type.startsWith("m.secret_storage")) { - this._getUpdatedStatus(); + this.getUpdatedStatus(); } }; - _onBootstrapClick = () => { - this._bootstrapCrossSigning({ forceReset: false }); + private onBootstrapClick = () => { + this.bootstrapCrossSigning({ forceReset: false }); }; - onStatusChanged = () => { - this._getUpdatedStatus(); + private onStatusChanged = () => { + this.getUpdatedStatus(); }; - async _getUpdatedStatus() { + private async getUpdatedStatus(): Promise { const cli = MatrixClientPeg.get(); const pkCache = cli.getCrossSigningCacheCallbacks(); const crossSigning = cli.crypto.crossSigningInfo; const secretStorage = cli.crypto.secretStorage; - const crossSigningPublicKeysOnDevice = crossSigning.getId(); - const crossSigningPrivateKeysInStorage = await crossSigning.isStoredInSecretStorage(secretStorage); + const crossSigningPublicKeysOnDevice = Boolean(crossSigning.getId()); + const crossSigningPrivateKeysInStorage = Boolean(await crossSigning.isStoredInSecretStorage(secretStorage)); const masterPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("master")); const selfSigningPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("self_signing")); const userSigningPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("user_signing")); @@ -110,8 +112,8 @@ export default class CrossSigningPanel extends React.PureComponent { * 3. All keys are loaded and there's nothing to do. * @param {bool} [forceReset] Bootstrap again even if keys already present */ - _bootstrapCrossSigning = async ({ forceReset = false }) => { - this.setState({ error: null }); + private bootstrapCrossSigning = async ({ forceReset = false }): Promise => { + this.setState({ error: undefined }); try { const cli = MatrixClientPeg.get(); await cli.bootstrapCrossSigning({ @@ -135,20 +137,20 @@ export default class CrossSigningPanel extends React.PureComponent { this.setState({ error: e }); console.error("Error bootstrapping cross-signing", e); } - if (this._unmounted) return; - this._getUpdatedStatus(); - } + if (this.unmounted) return; + this.getUpdatedStatus(); + }; - _resetCrossSigning = () => { + private resetCrossSigning = (): void => { Modal.createDialog(ConfirmDestroyCrossSigningDialog, { onFinished: (act) => { if (!act) return; - this._bootstrapCrossSigning({ forceReset: true }); + this.bootstrapCrossSigning({ forceReset: true }); }, }); - } + }; - render() { + public render() { const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); const { error, @@ -208,7 +210,7 @@ export default class CrossSigningPanel extends React.PureComponent { // TODO: determine how better to expose this to users in addition to prompts at login/toast if (!keysExistEverywhere && homeserverSupportsCrossSigning) { actions.push( - + { _t("Set up") } , ); @@ -216,7 +218,7 @@ export default class CrossSigningPanel extends React.PureComponent { if (keysExistAnywhere) { actions.push( - + { _t("Reset") } , ); From 7c8637f5dbba2f13b7cc395465e7c421cfef9371 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 13 Aug 2021 18:18:48 +0100 Subject: [PATCH 30/31] Add MatrixEvent type --- src/components/views/settings/CrossSigningPanel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/settings/CrossSigningPanel.tsx b/src/components/views/settings/CrossSigningPanel.tsx index 3dd9ea0512..99b1ba3402 100644 --- a/src/components/views/settings/CrossSigningPanel.tsx +++ b/src/components/views/settings/CrossSigningPanel.tsx @@ -24,6 +24,7 @@ import Spinner from '../elements/Spinner'; import InteractiveAuthDialog from '../dialogs/InteractiveAuthDialog'; import ConfirmDestroyCrossSigningDialog from '../dialogs/security/ConfirmDestroyCrossSigningDialog'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { MatrixEvent } from '../../../../../matrix-js-sdk/src'; interface IState { error?: Error; @@ -63,7 +64,7 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> { cli.removeListener("crossSigning.keysChanged", this.onStatusChanged); } - private onAccountData = (event): void => { + private onAccountData = (event: MatrixEvent): void => { const type = event.getType(); if (type.startsWith("m.cross_signing") || type.startsWith("m.secret_storage")) { this.getUpdatedStatus(); From c28d449f3fec9881986faef28e2d1b0d3ba2c235 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 13 Aug 2021 18:21:59 +0100 Subject: [PATCH 31/31] Fix import thanks vscode --- src/components/views/settings/CrossSigningPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/settings/CrossSigningPanel.tsx b/src/components/views/settings/CrossSigningPanel.tsx index 99b1ba3402..21e38a762a 100644 --- a/src/components/views/settings/CrossSigningPanel.tsx +++ b/src/components/views/settings/CrossSigningPanel.tsx @@ -24,7 +24,7 @@ import Spinner from '../elements/Spinner'; import InteractiveAuthDialog from '../dialogs/InteractiveAuthDialog'; import ConfirmDestroyCrossSigningDialog from '../dialogs/security/ConfirmDestroyCrossSigningDialog'; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { MatrixEvent } from '../../../../../matrix-js-sdk/src'; +import { MatrixEvent } from 'matrix-js-sdk/src'; interface IState { error?: Error;