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/22] 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<IProps, IState> { completionList: [], // how far down the completion list we are (THIS IS 1-INDEXED!) - selectionOffset: COMPOSER_SELECTED, + selectionOffset: 1, // whether we should show completions if they're available shouldShowCompletions: true, @@ -112,7 +110,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { completions: [], completionList: [], // Reset selected completion - selectionOffset: COMPOSER_SELECTED, + selectionOffset: 1, // Hide the autocomplete box hide: true, }); @@ -148,26 +146,31 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { 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<IProps, IState> { 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<IProps, IState> { hide = () => { this.setState({ hide: true, - selectionOffset: 0, + selectionOffset: 1, completions: [], completionList: [], }); @@ -232,8 +235,13 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { }); } + 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<IProps, IState> 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<IProps, IState> if (isEmpty) { this.formatBarRef.current.hide(); } - this.setState({autoComplete: this.props.model.autoComplete}); + this.setState({ + autoComplete: this.props.model.autoComplete, + // if a change is happening then clear the showVisualBell + showVisualBell: diff ? false : this.state.showVisualBell, + }); this.historyManager.tryPush(this.props.model, selection, inputType, diff); let isTyping = !this.props.model.isEmpty; @@ -490,6 +495,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState> } 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<IProps, IState> 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<IProps, IState> 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<IProps, IState> 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<IProps, IState> }; const {completionIndex} = this.state; + const hasAutocomplete = Boolean(this.state.autoComplete); + let activeDescendant; + if (hasAutocomplete && completionIndex >= 0) { + activeDescendant = generateCompletionDomId(completionIndex); + } return (<div className={wrapperClasses}> { autoComplete } @@ -697,10 +710,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState> aria-label={this.props.label} role="textbox" aria-multiline="true" - aria-autocomplete="both" + aria-autocomplete="list" aria-haspopup="listbox" - aria-expanded={Boolean(this.state.autoComplete)} - aria-activedescendant={completionIndex >= 0 ? generateCompletionDomId(completionIndex) : undefined} + aria-expanded={hasAutocomplete} + aria-owns="mx_Autocomplete" + aria-activedescendant={activeDescendant} dir="auto" /> </div>); 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/22] 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<IProps, IState> { 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/22] 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<ICompletion[]> { - return []; - } + abstract getCompletions(query: string, selection: ISelectionRange, force: boolean): Promise<ICompletion[]>; - 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 ( <div className="mx_Autocomplete_Completion_container_block" - role="listbox" + role="presentation" aria-label={_t("Command Autocomplete")} > { 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 ( <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate" - role="listbox" + role="presentation" aria-label={_t("Community Autocomplete")} > { 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 ( <div className="mx_Autocomplete_Completion_container_block" - role="listbox" + role="presentation" aria-label={_t("DuckDuckGo Results")} > { 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 ( <div className="mx_Autocomplete_Completion_container_pill" - role="listbox" + role="presentation" aria-label={_t("Emoji Autocomplete")} > { 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 ( <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate" - role="listbox" + role="presentation" aria-label={_t("Notification Autocomplete")} > { 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 ( <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate" - role="listbox" + role="presentation" aria-label={_t("Room Autocomplete")} > { 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 ( <div className="mx_Autocomplete_Completion_container_pill" - role="listbox" + role="presentation" aria-label={_t("User Autocomplete")} > { 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<IProps, IState> { return completions.length > 0 ? ( - <div key={i} className="mx_Autocomplete_ProviderSection"> + <div key={i} className="mx_Autocomplete_ProviderSection" role="presentation"> <div className="mx_Autocomplete_provider_name">{ completionResult.provider.getName() }</div> { completionResult.provider.renderCompletions(completions) } </div> @@ -306,7 +306,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { }).filter((completion) => !!completion); return !this.state.hide && renderedCompletions.length > 0 ? ( - <div className="mx_Autocomplete" ref={this.containerRef}> + <div id="mx_Autocomplete" className="mx_Autocomplete" ref={this.containerRef} role="listbox"> { renderedCompletions } </div> ) : null; 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/22] 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<MessageComposerAction>[] => { const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => { 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<IProps, IState> 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<IProps, IState> handled = true; break; } - if (handled) { - event.preventDefault(); - event.stopPropagation(); - return; - } - - const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event); - if (model.autoComplete && model.autoComplete.hasCompletions()) { - const autoComplete = model.autoComplete; - switch (autocompleteAction) { - case AutocompleteAction.CompleteOrPrevSelection: - case AutocompleteAction.PrevSelection: - autoComplete.selectPreviousSelection(); - handled = true; - break; - case AutocompleteAction.CompleteOrNextSelection: - case AutocompleteAction.NextSelection: - autoComplete.selectNextSelection(); - handled = true; - break; - case AutocompleteAction.Cancel: - autoComplete.onEscape(event); - handled = true; - break; - default: - return; // don't preventDefault on anything else - } - } else if (autocompleteAction === AutocompleteAction.CompleteOrPrevSelection - || autocompleteAction === AutocompleteAction.CompleteOrNextSelection) { - // there is no current autocomplete window, try to open it - this.tabCompleteName(); - handled = true; - } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { - this.formatBarRef.current.hide(); - } - if (handled) { event.preventDefault(); event.stopPropagation(); 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/22] 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<ICompletion[]> { - return []; - } + ): Promise<ICompletion[]>; 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/22] 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<IProps, IState> { 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<IProps, IState> { 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<IProps, IState> 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<void>; 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/22] 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 <Loader />; - } else { - return null; - } - } - - const StageComponent = getEntryComponentForLoginType(stage); - return ( - <StageComponent - ref={this._stageComponent} - loginType={stage} - matrixClient={this.props.matrixClient} - authSessionId={this._authLogic.getSessionId()} - clientSecret={this._authLogic.getClientSecret()} - stageParams={this._authLogic.getStageParams(stage)} - submitAuthDict={this._submitAuthDict} - errorText={this.state.stageErrorText} - busy={this.state.busy} - inputs={this.props.inputs} - stageState={this.state.stageState} - fail={this._onAuthStageFailed} - setEmailSid={this._setEmailSid} - showContinue={!this.props.continueIsManaged} - onPhaseChange={this._onPhaseChange} - continueText={this.props.continueText} - continueKind={this.props.continueKind} - onCancel={this._onStageCancel} - /> - ); - } - - _onAuthStageFailed = e => { - this.props.onAuthFinished(false, e); - }; - - _setEmailSid = sid => { - this._authLogic.setEmailSid(sid); - }; - - render() { - let error = null; - if (this.state.errorText) { - error = ( - <div className="error"> - { this.state.errorText } - </div> - ); - } - - return ( - <div> - <div> - { this._renderCurrentStage() } - { error } - </div> - </div> - ); - } -} 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<IAuthData>; + // 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<IProps, IState> { + private readonly authLogic: InteractiveAuth; + private readonly _intervalId: NodeJS.Timeout = null; + private readonly stageComponent = createRef<IStageComponent>(); + + 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<IAuthData> => { + // 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 <Spinner />; + } else { + return null; + } + } + + const StageComponent = getEntryComponentForLoginType(stage); + return ( + <StageComponent + ref={this.stageComponent as any} + loginType={stage} + matrixClient={this.props.matrixClient} + authSessionId={this.authLogic.getSessionId()} + clientSecret={this.authLogic.getClientSecret()} + stageParams={this.authLogic.getStageParams(stage)} + submitAuthDict={this.submitAuthDict} + errorText={this.state.stageErrorText} + busy={this.state.busy} + inputs={this.props.inputs} + stageState={this.state.stageState} + fail={this.onAuthStageFailed} + setEmailSid={this.setEmailSid} + showContinue={!this.props.continueIsManaged} + onPhaseChange={this.onPhaseChange} + continueText={this.props.continueText} + continueKind={this.props.continueKind} + onCancel={this.onStageCancel} + /> + ); + } + + 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 = ( + <div className="error"> + { this.state.errorText } + </div> + ); + } + + return ( + <div> + <div> + { this.renderCurrentStage() } + { error } + </div> + </div> + ); + } +} 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<IAuthEntryProps> { } } -export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component { +export interface IStageComponentProps extends IAuthEntryProps { + clientSecret?: string; + stageParams?: Record<string, any>; + 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<React.PropsWithRef<IStageComponentProps>> { + 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<IProps, ISt this.initAuth(/* shouldErase= */false); } - private onStagePhaseChange = (stage: string, phase: string): void => { + 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<IProps, ISt this.setState({ errStr: _t("There was a problem communicating with the server. Please try again.") }); }; - private onUIAuthComplete = (auth: any): void => { + 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<IProps, ISt <InteractiveAuth matrixClient={MatrixClientPeg.get()} authData={this.state.authData} - makeRequest={this.onUIAuthComplete} + makeRequest={this.onUIAuthComplete as any} onAuthFinished={this.onUIAuthFinished} onStagePhaseChange={this.onStagePhaseChange} continueText={this.state.continueText} diff --git a/src/settings/controllers/NotificationControllers.ts b/src/settings/controllers/NotificationControllers.ts index cc5c040a89..1201d50317 100644 --- a/src/settings/controllers/NotificationControllers.ts +++ b/src/settings/controllers/NotificationControllers.ts @@ -20,7 +20,7 @@ import { MatrixClientPeg } from '../../MatrixClientPeg'; import { SettingLevel } from "../SettingLevel"; // XXX: This feels wrong. -import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; +import { Action, PushProcessor } from "matrix-js-sdk/src/pushprocessor"; // .m.rule.master being enabled means all events match that push rule // default action on this rule is dont_notify, but it could be something else @@ -35,7 +35,7 @@ export function isPushNotifyDisabled(): boolean { } // If the rule is enabled then check it does not notify on everything - return masterRule.enabled && !masterRule.actions.includes("notify"); + return masterRule.enabled && !masterRule.actions.includes(Action.Notify); } function getNotifier(): any { // TODO: [TS] Formal type that doesn't cause a cyclical reference. From de88a3960477792048e9d4b96d5dae4c9d61b9af Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 15 Jul 2021 10:09:24 +0100 Subject: [PATCH 08/22] 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<IProps, IState> { this.applyNewProps(); } - private applyNewProps(oldQuery?: string, oldRoom?: Room) { + private applyNewProps(oldQuery?: string, oldRoom?: Room): void { if (oldRoom && this.props.room.roomId !== oldRoom.roomId) { this.autocompleter.destroy(); this.autocompleter = new Autocompleter(this.props.room); @@ -103,7 +103,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { this.autocompleter.destroy(); } - complete(query: string, selection: ISelectionRange) { + private complete(query: string, selection: ISelectionRange): Promise<void> { this.queryRequested = query; if (this.debounceCompletionsRequest) { clearTimeout(this.debounceCompletionsRequest); @@ -134,7 +134,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { }); } - processQuery(query: string, selection: ISelectionRange) { + private processQuery(query: string, selection: ISelectionRange): Promise<void> { return this.autocompleter.getCompletions( query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES, ).then((completions) => { @@ -146,7 +146,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { }); } - processCompletions(completions: IProviderCompletions[]) { + private processCompletions(completions: IProviderCompletions[]): void { const completionList = flatMap(completions, (provider) => provider.completions); // Reset selection when completion list becomes empty. @@ -186,16 +186,16 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { }); } - hasSelection(): boolean { + public hasSelection(): boolean { return this.countCompletions() > 0 && this.state.selectionOffset !== 0; } - countCompletions(): number { + public countCompletions(): number { return this.state.completionList.length; } // called from MessageComposerInput - moveSelection(delta: number) { + public moveSelection(delta: number): void { const completionCount = this.countCompletions(); if (completionCount === 0) return; // there are no items to move the selection through @@ -204,7 +204,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { 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<IProps, IState> { this.hide(); } - hide = () => { + private hide = (): void => { this.setState({ hide: true, selectionOffset: 1, @@ -226,7 +226,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { }); }; - forceComplete() { + public forceComplete(): Promise<number> { return new Promise((resolve) => { this.setState({ forceComplete: true, @@ -239,11 +239,11 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> { }); } - 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<IProps, IState> { 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<IProps, IState> model.autoComplete.close(); } } else { - this.setState({showVisualBell: true}); + this.setState({ showVisualBell: true }); } } catch (err) { console.error(err); From 4c7c10abf824aab0dd21fdb7a8ff214b35690f9f Mon Sep 17 00:00:00 2001 From: Libexus <libexus@gmail.com> Date: Wed, 21 Jul 2021 09:44:39 +0200 Subject: [PATCH 09/22] Remove misplaced bracket in a translation string --- src/components/views/messages/CallEvent.tsx | 2 +- src/i18n/strings/en_EN.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index c0be3b46bb..534458e75b 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -138,7 +138,7 @@ export default class CallEvent extends React.Component<IProps, IState> { } else if (hangupReason === CallErrorCode.UserBusy) { reason = _t("The user you called is busy."); } else { - reason = _t('Unknown failure: %(reason)s)', { reason: hangupReason }); + reason = _t('Unknown failure: %(reason)s', { reason: hangupReason }); } return ( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f081ec823b..538eb74954 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1849,7 +1849,7 @@ "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", "No answer": "No answer", - "Unknown failure: %(reason)s)": "Unknown failure: %(reason)s)", + "Unknown failure: %(reason)s": "Unknown failure: %(reason)s", "This call has failed": "This call has failed", "You missed this call": "You missed this call", "Call back": "Call back", 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 10/22] 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<IProps, IState> { private readonly authLogic: InteractiveAuth; - private readonly _intervalId: NodeJS.Timeout = null; + private readonly intervalId: number = null; private readonly stageComponent = createRef<IStageComponent>(); private unmounted = false; @@ -121,14 +121,14 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS }); if (this.props.poll) { - this._intervalId = setInterval(() => { + 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<IProps, IS componentWillUnmount() { this.unmounted = true; - if (this._intervalId !== null) { - clearInterval(this._intervalId); + if (this.intervalId !== null) { + clearInterval(this.intervalId); } } diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index 0331a4d5b4..423738acb8 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ChangeEvent, ComponentClass, createRef, FormEvent, MouseEvent, RefObject } from 'react'; +import React, { ChangeEvent, createRef, FormEvent, MouseEvent } 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'; diff --git a/src/settings/controllers/NotificationControllers.ts b/src/settings/controllers/NotificationControllers.ts index 1201d50317..09e4e1dd1a 100644 --- a/src/settings/controllers/NotificationControllers.ts +++ b/src/settings/controllers/NotificationControllers.ts @@ -20,7 +20,8 @@ import { MatrixClientPeg } from '../../MatrixClientPeg'; import { SettingLevel } from "../SettingLevel"; // XXX: This feels wrong. -import { Action, PushProcessor } from "matrix-js-sdk/src/pushprocessor"; +import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; +import { PushRuleActionName } from "matrix-js-sdk/src/@types/PushRules"; // .m.rule.master being enabled means all events match that push rule // default action on this rule is dont_notify, but it could be something else @@ -35,7 +36,7 @@ export function isPushNotifyDisabled(): boolean { } // If the rule is enabled then check it does not notify on everything - return masterRule.enabled && !masterRule.actions.includes(Action.Notify); + return masterRule.enabled && !masterRule.actions.includes(PushRuleActionName.Notify); } function getNotifier(): any { // TODO: [TS] Formal type that doesn't cause a cyclical reference. From 5d98c6f02dab67eeb61b363d4b5dafac79e89776 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 11 Aug 2021 21:45:49 +0100 Subject: [PATCH 11/22] 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<IProps, IS this.props.onAuthFinished(false, ERROR_USER_CANCELLED); }; - private renderCurrentStage() { + private renderCurrentStage(): JSX.Element { const stage = this.state.authStage; if (!stage) { if (this.state.busy) { From 69cf64249f4bb3ffdf3f4269b818ac691d424bd4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 11 Aug 2021 21:50:26 +0100 Subject: [PATCH 12/22] 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<IProps, ISt <InteractiveAuth matrixClient={MatrixClientPeg.get()} authData={this.state.authData} + // XXX: onUIAuthComplete breaches the expected method contract, it gets away with it because it + // knows the entire app is about to die as a result of the account deactivation. makeRequest={this.onUIAuthComplete as any} onAuthFinished={this.onUIAuthFinished} onStagePhaseChange={this.onStagePhaseChange} 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 13/22] 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<IProps, IState> } 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<IProps, IState> 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 c79852a9f0e245a5ee3c1ee0513f50695b8e5ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com> Date: Fri, 13 Aug 2021 10:59:59 +0200 Subject: [PATCH 14/22] Left align call tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> --- 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<IProps, IState> { } else if (this.props.layout == Layout.IRC) { avatarSize = 14; needsSenderProfile = true; - } else if (this.props.continuation && this.props.tileShape !== TileShape.FileGrid) { - // no avatar or sender profile for continuation messages + } else if ( + (this.props.continuation && this.props.tileShape !== TileShape.FileGrid) || + 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?= <simon.bra.ag@gmail.com> Date: Fri, 13 Aug 2021 11:19:14 +0200 Subject: [PATCH 15/22] Add "No answer" state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> --- 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<IProps, IState> { } else if (hangupReason === CallErrorCode.InviteTimeout) { return ( <div className="mx_CallEvent_content"> - { _t("Missed call") } + { _t("No answer") } { this.renderCallBackButton(_t("Call back")) } </div> ); 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?= <simon.bra.ag@gmail.com> Date: Fri, 13 Aug 2021 11:34:11 +0200 Subject: [PATCH 16/22] Add declined call buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> --- 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 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M0 4.81815C0 3.76379 0.89543 2.90906 2 2.90906H9.33333C10.4379 2.90906 11.3333 3.76379 11.3333 4.81815V11.1818C11.3333 12.2361 10.4379 13.0909 9.33333 13.0909H2C0.895429 13.0909 0 12.2361 0 11.1818V4.81815ZM12.6667 6.09089L14.9169 4.37255C15.3534 4.03921 16 4.33587 16 4.86947V11.1305C16 11.6641 15.3534 11.9607 14.9169 11.6274L12.6667 9.90907V6.09089ZM7.82332 5.81539C7.96503 5.95709 7.96503 6.18685 7.82332 6.32855L6.17983 7.97204L7.89372 9.68593C8.03543 9.82763 8.03543 10.0574 7.89372 10.1991C7.75201 10.3408 7.52226 10.3408 7.38055 10.1991L5.66667 8.48521L3.95278 10.1991C3.81107 10.3408 3.58132 10.3408 3.43961 10.1991C3.29791 10.0574 3.29791 9.82763 3.43961 9.68593L5.1535 7.97204L3.51001 6.32855C3.36831 6.18685 3.36831 5.95709 3.51001 5.81539C3.65172 5.67368 3.88147 5.67368 4.02318 5.81539L5.66667 7.45887L7.31015 5.81539C7.45186 5.67368 7.68161 5.67368 7.82332 5.81539Z" fill="#737D8C"/> +</svg> 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 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M5.35116 10.6409C6.11185 11.4622 7.94306 12.8843 8.44217 13.1761C8.47169 13.1934 8.50549 13.2135 8.54328 13.2359C9.30489 13.6887 11.6913 15.1074 13.4301 13.7797C14.7772 12.7511 14.3392 11.594 13.8858 11.2501C13.5754 11.0086 12.6608 10.3431 11.8003 9.74356C10.9553 9.15489 10.4844 9.62653 10.1659 9.94543C10.1601 9.95129 10.1543 9.9571 10.1485 9.96285L9.50791 10.6035C9.34477 10.7666 9.0966 10.7071 8.8589 10.5204C8.00599 9.87084 7.37856 9.24399 7.06465 8.93008L7.06201 8.92744C6.74815 8.61357 6.12909 7.99392 5.47955 7.14101C5.29283 6.90331 5.23329 6.65515 5.39643 6.49201L6.03708 5.85136C6.04283 5.84561 6.04864 5.83981 6.0545 5.83396C6.3734 5.51555 6.84504 5.04464 6.25636 4.19966C5.65687 3.33915 4.9913 2.42455 4.74984 2.11412C4.40588 1.66071 3.2488 1.22269 2.22021 2.5698C0.89255 4.30858 2.31122 6.69502 2.76397 7.45663C2.78644 7.49443 2.80653 7.52822 2.82379 7.55774C3.11562 8.05685 4.52989 9.88025 5.35116 10.6409Z" fill="#737D8C"/> +<path d="M13.7979 2.05203C13.9599 1.8876 13.9599 1.62101 13.7979 1.45658C13.636 1.29214 13.3734 1.29214 13.2114 1.45658L11.3332 3.36362L9.4549 1.45658C9.29295 1.29214 9.03037 1.29214 8.86842 1.45658C8.70647 1.62101 8.70647 1.8876 8.86842 2.05203L10.7467 3.95907L8.78797 5.9478C8.62602 6.11223 8.62602 6.37883 8.78797 6.54326C8.94992 6.70769 9.21249 6.70769 9.37444 6.54326L11.3332 4.55453L13.2919 6.54326C13.4538 6.70769 13.7164 6.70769 13.8784 6.54326C14.0403 6.37883 14.0403 6.11223 13.8784 5.9478L11.9196 3.95907L13.7979 2.05203Z" fill="#737D8C"/> +</svg> From cda91e44e00116c0e22d5f8357d2724222d7acc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com> Date: Fri, 13 Aug 2021 11:37:17 +0200 Subject: [PATCH 17/22] 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 <simon.bra.ag@gmail.com> --- 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<IProps, IState> { 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?= <simon.bra.ag@gmail.com> Date: Fri, 13 Aug 2021 18:42:55 +0200 Subject: [PATCH 18/22] 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 <simon.bra.ag@gmail.com> --- 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 <dave@matrix.org> Date: Fri, 13 Aug 2021 18:07:58 +0100 Subject: [PATCH 19/22] 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<void> { 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<void> => { + 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( - <AccessibleButton key="setup" kind="primary" onClick={this._onBootstrapClick}> + <AccessibleButton key="setup" kind="primary" onClick={this.onBootstrapClick}> { _t("Set up") } </AccessibleButton>, ); @@ -216,7 +218,7 @@ export default class CrossSigningPanel extends React.PureComponent { if (keysExistAnywhere) { actions.push( - <AccessibleButton key="reset" kind="danger" onClick={this._resetCrossSigning}> + <AccessibleButton key="reset" kind="danger" onClick={this.resetCrossSigning}> { _t("Reset") } </AccessibleButton>, ); From 7c8637f5dbba2f13b7cc395465e7c421cfef9371 Mon Sep 17 00:00:00 2001 From: David Baker <dave@matrix.org> Date: Fri, 13 Aug 2021 18:18:48 +0100 Subject: [PATCH 20/22] 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 <dave@matrix.org> Date: Fri, 13 Aug 2021 18:21:59 +0100 Subject: [PATCH 21/22] 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; From ae1905573404e51808806ada21b35c5f3150e30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com> Date: Mon, 16 Aug 2021 09:34:28 +0200 Subject: [PATCH 22/22] Fix long display names in call toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> --- res/css/views/toasts/_IncomingCallToast.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/res/css/views/toasts/_IncomingCallToast.scss b/res/css/views/toasts/_IncomingCallToast.scss index 975628f948..eb80f2d5cf 100644 --- a/res/css/views/toasts/_IncomingCallToast.scss +++ b/res/css/views/toasts/_IncomingCallToast.scss @@ -30,7 +30,14 @@ limitations under the License. font-size: $font-15px; line-height: $font-18px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 2px; + margin-right: 6px; + + max-width: 200px; } .mx_CallEvent_type {