diff --git a/res/css/_components.scss b/res/css/_components.scss index bc7d4fc85f..396a5560a6 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -159,10 +159,10 @@ @import "./views/groups/_GroupPublicityToggle.scss"; @import "./views/groups/_GroupRoomList.scss"; @import "./views/groups/_GroupUserSettings.scss"; +@import "./views/messages/_CallEvent.scss"; @import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_DateSeparator.scss"; @import "./views/messages/_EventTileBubble.scss"; -@import "./views/messages/_CallEvent.scss"; @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; @@ -172,7 +172,6 @@ @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MVideoBody.scss"; -@import "./views/messages/_MVoiceMessageBody.scss"; @import "./views/messages/_MediaBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; @@ -201,8 +200,8 @@ @import "./views/rooms/_E2EIcon.scss"; @import "./views/rooms/_EditMessageComposer.scss"; @import "./views/rooms/_EntityTile.scss"; -@import "./views/rooms/_EventTile.scss"; @import "./views/rooms/_EventBubbleTile.scss"; +@import "./views/rooms/_EventTile.scss"; @import "./views/rooms/_GroupLayout.scss"; @import "./views/rooms/_IRCLayout.scss"; @import "./views/rooms/_JumpToBottomButton.scss"; diff --git a/res/css/views/audio_messages/_AudioPlayer.scss b/res/css/views/audio_messages/_AudioPlayer.scss index 9a65ad008f..77dcebbb9a 100644 --- a/res/css/views/audio_messages/_AudioPlayer.scss +++ b/res/css/views/audio_messages/_AudioPlayer.scss @@ -14,9 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_AudioPlayer_container { +.mx_MediaBody.mx_AudioPlayer_container { padding: 16px 12px 12px 12px; - max-width: 267px; // use max to make the control fit in the files/pinned panels .mx_AudioPlayer_primaryContainer { display: flex; diff --git a/res/css/views/audio_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss index 5548f6198e..773fc50fb9 100644 --- a/res/css/views/audio_messages/_PlaybackContainer.scss +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -18,10 +18,10 @@ limitations under the License. // are shared amongst multiple voice message components. // Container for live recording and playback controls -.mx_VoiceMessagePrimaryContainer { - // 7px top and bottom for visual design. 12px left & right, but the waveform (right) - // has a 1px padding on it that we want to account for. - padding: 7px 12px 7px 11px; +.mx_MediaBody.mx_VoiceMessagePrimaryContainer { + // The waveform (right) has a 1px padding on it that we want to account for, otherwise + // inherit from mx_MediaBody + padding-right: 11px; // Cheat at alignment a bit display: flex; diff --git a/res/css/views/dialogs/_ForwardDialog.scss b/res/css/views/dialogs/_ForwardDialog.scss index 95d7ce74c4..e018f60172 100644 --- a/res/css/views/dialogs/_ForwardDialog.scss +++ b/res/css/views/dialogs/_ForwardDialog.scss @@ -36,6 +36,10 @@ limitations under the License. flex-shrink: 0; overflow-y: auto; + .mx_EventTile[data-layout=bubble] { + margin-top: 20px; + } + div { pointer-events: none; } diff --git a/res/css/views/elements/_ReplyThread.scss b/res/css/views/elements/_ReplyThread.scss index 44532ea6a7..032cb49359 100644 --- a/res/css/views/elements/_ReplyThread.scss +++ b/res/css/views/elements/_ReplyThread.scss @@ -19,8 +19,9 @@ limitations under the License. margin-left: 0; margin-right: 0; margin-bottom: 8px; - padding-left: 10px; - border-left: 4px solid $button-bg-color; + padding: 0 10px; + border-left: 2px solid $button-bg-color; + border-radius: 2px; .mx_ReplyThread_show { cursor: pointer; diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index b91c461ce5..403f671673 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -60,12 +60,6 @@ limitations under the License. } .mx_MFileBody_info { - background-color: $message-body-panel-bg-color; - border-radius: 12px; - width: 243px; // same width as a playable voice message, accounting for padding - padding: 6px 12px; - color: $message-body-panel-fg-color; - .mx_MFileBody_info_icon { background-color: $message-body-panel-icon-bg-color; border-radius: 20px; diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss index 0a199c1f45..f5d8131e6e 100644 --- a/res/css/views/messages/_MImageBody.scss +++ b/res/css/views/messages/_MImageBody.scss @@ -21,11 +21,7 @@ $timelineImageBorderRadius: 4px; } .mx_MImageBody_thumbnail { - position: absolute; - width: 100%; - height: 100%; - left: 0; - top: 0; + object-fit: contain; border-radius: $timelineImageBorderRadius; display: flex; diff --git a/res/css/views/messages/_MediaBody.scss b/res/css/views/messages/_MediaBody.scss index 12e441750c..7f4bfd3fdc 100644 --- a/res/css/views/messages/_MediaBody.scss +++ b/res/css/views/messages/_MediaBody.scss @@ -20,9 +20,11 @@ limitations under the License. .mx_MediaBody { background-color: $message-body-panel-bg-color; border-radius: 12px; + max-width: 243px; // use max-width instead of width so it fits within right panels color: $message-body-panel-fg-color; font-size: $font-14px; line-height: $font-24px; -} + padding: 6px 12px; +} diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index c66f635ffe..6bc75e650e 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -80,7 +80,7 @@ limitations under the License. .mx_MessageActionBar { right: 0; - transform: translate3d(50%, 50%, 0); + transform: translate3d(90%, 50%, 0); } --backgroundColor: $eventbubble-others-bg; @@ -91,7 +91,7 @@ limitations under the License. float: right; > a { left: auto; - right: -48px; + right: -68px; } } .mx_SenderProfile { @@ -126,7 +126,9 @@ limitations under the License. margin: 0 -12px 0 -9px; > a { position: absolute; - left: -48px; + padding: 10px 20px; + top: 0; + left: -68px; } } @@ -254,7 +256,7 @@ limitations under the License. } .mx_MessageActionBar { - transform: translate3d(50%, 0, 0); + transform: translate3d(90%, 0, 0); } } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 72328fab77..2f06d47877 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -136,6 +136,10 @@ $hover-select-border: 4px; padding-left: calc($left-gutter + 18px); } + & ~ .mx_EventListSummary .mx_EventTile_line { + padding-left: calc($left-gutter); + } + &.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { padding-left: calc($left-gutter + 18px - $hover-select-border); } @@ -208,43 +212,11 @@ $hover-select-border: 4px; text-decoration: none; } - /* all the overflow-y: hidden; are to trap Zalgos - - but they introduce an implicit overflow-x: auto. - so make that explicitly hidden too to avoid random - horizontal scrollbars occasionally appearing, like in - https://github.com/vector-im/vector-web/issues/1154 - */ - .mx_EventTile_content { - display: block; - overflow-y: hidden; - overflow-x: hidden; - margin-right: 34px; - } - /* De-zalgoing */ .mx_EventTile_body { overflow-y: hidden; } - /* Spoiler stuff */ - .mx_EventTile_spoiler { - cursor: pointer; - } - - .mx_EventTile_spoiler_reason { - color: $event-timestamp-color; - font-size: $font-11px; - } - - .mx_EventTile_spoiler_content { - filter: blur(5px) saturate(0.1) sepia(1); - transition-duration: 0.5s; - } - - .mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content { - filter: none; - } - &:hover.mx_EventTile_verified .mx_EventTile_line, &:hover.mx_EventTile_unverified .mx_EventTile_line, &:hover.mx_EventTile_unknown .mx_EventTile_line { @@ -307,6 +279,36 @@ $hover-select-border: 4px; } } +/* all the overflow-y: hidden; are to trap Zalgos - + but they introduce an implicit overflow-x: auto. + so make that explicitly hidden too to avoid random + horizontal scrollbars occasionally appearing, like in + https://github.com/vector-im/vector-web/issues/1154 */ +.mx_EventTile_content { + overflow-y: hidden; + overflow-x: hidden; + margin-right: 34px; +} + +/* Spoiler stuff */ +.mx_EventTile_spoiler { + cursor: pointer; +} + +.mx_EventTile_spoiler_reason { + color: $event-timestamp-color; + font-size: $font-11px; +} + +.mx_EventTile_spoiler_content { + filter: blur(5px) saturate(0.1) sepia(1); + transition-duration: 0.5s; +} + +.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content { + filter: none; +} + .mx_RoomView_timeline_rr_enabled { .mx_EventTile:not([data-layout=bubble]) { @@ -469,6 +471,10 @@ $hover-select-border: 4px; background-color: $header-panel-bg-color; } + pre code > * { + display: inline-block; + } + pre { // have to use overlay rather than auto otherwise Linux and Windows // Chrome gets very confused about vertical spacing: diff --git a/res/css/views/rooms/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss index 0832337ecd..b3eff7e960 100644 --- a/res/css/views/rooms/_LinkPreviewWidget.scss +++ b/res/css/views/rooms/_LinkPreviewWidget.scss @@ -19,7 +19,8 @@ limitations under the License. margin-right: 15px; margin-bottom: 15px; display: flex; - border-left: 4px solid $preview-widget-bar-color; + border-left: 2px solid $preview-widget-bar-color; + border-radius: 2px; color: $preview-widget-fg-color; } diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss index 9f6a8d52ce..4b7eb54188 100644 --- a/res/css/views/rooms/_SendMessageComposer.scss +++ b/res/css/views/rooms/_SendMessageComposer.scss @@ -29,8 +29,10 @@ limitations under the License. display: flex; flex-direction: column; // min-height at this level so the mx_BasicMessageComposer_input - // still stays vertically centered when less than 50px - min-height: 50px; + // still stays vertically centered when less than 55px. + // We also set this to ensure the voice message recording widget + // doesn't cause a jump. + min-height: 55px; .mx_BasicMessageComposer_input { padding: 3px 0; diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index 94983a60bf..ca5a6f0a66 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -15,8 +15,7 @@ limitations under the License. */ .mx_AppearanceUserSettingsTab_fontSlider, -.mx_AppearanceUserSettingsTab_fontSlider_preview, -.mx_AppearanceUserSettingsTab_Layout { +.mx_AppearanceUserSettingsTab_fontSlider_preview { @mixin mx_Settings_fullWidthField; } @@ -45,6 +44,11 @@ limitations under the License. border-radius: 10px; padding: 0 16px 9px 16px; pointer-events: none; + display: flow-root; + + .mx_EventTile[data-layout=bubble] { + margin-top: 30px; + } .mx_EventTile_msgOption { display: none; @@ -154,13 +158,10 @@ limitations under the License. .mx_AppearanceUserSettingsTab_Layout_RadioButtons { display: flex; flex-direction: row; + gap: 24px; color: $primary-fg-color; - .mx_AppearanceUserSettingsTab_spacer { - width: 24px; - } - > .mx_AppearanceUserSettingsTab_Layout_RadioButton { flex-grow: 0; flex-shrink: 1; @@ -210,6 +211,21 @@ limitations under the License. .mx_RadioButton_checked { background-color: rgba($accent-color, 0.08); } + + .mx_EventTile { + margin: 0; + &[data-layout=bubble] { + margin-right: 40px; + } + &[data-layout=irc] { + > a { + display: none; + } + } + .mx_EventTile_line { + max-width: 90%; + } + } } .mx_AppearanceUserSettingsTab_Advanced { diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 2a4ebff034..655492661c 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -209,8 +209,8 @@ $user-tile-hover-bg-color: $header-panel-bg-color; $message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-bg-color: #394049; // "Dark Tile" -$message-body-panel-icon-fg-color: #21262C; // "Separator" -$message-body-panel-icon-bg-color: $tertiary-fg-color; +$message-body-panel-icon-fg-color: $secondary-fg-color; +$message-body-panel-icon-bg-color: #21262C; // "System Dark" $voice-record-stop-border-color: $quaternary-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; @@ -295,3 +295,11 @@ $eventbubble-reply-color: #C1C6CD; .hljs-tag { color: inherit; // Without this they'd be weirdly blue which doesn't match the theme } + +.hljs-addition { + background: #1a4b59; +} + +.hljs-deletion { + background: #53232a; +} diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 555ef4f66c..0c0197cfb0 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -207,8 +207,8 @@ $user-tile-hover-bg-color: $header-panel-bg-color; $message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-bg-color: #394049; -$message-body-panel-icon-fg-color: $primary-bg-color; -$message-body-panel-icon-bg-color: $secondary-fg-color; +$message-body-panel-icon-fg-color: $secondary-fg-color; +$message-body-panel-icon-bg-color: #21262C; // See non-legacy dark for variable information $voice-record-stop-border-color: #6F7882; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index f349a804a8..b7d45452ff 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -331,7 +331,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: $primary-bg-color; +$message-body-panel-icon-bg-color: #F4F6FA; // See non-legacy _light for variable information $voice-record-stop-symbol-color: #ff4b55; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index ef5f4d8c86..32722515d8 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -327,7 +327,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color; $message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-bg-color: #E3E8F0; // "Separator" $message-body-panel-icon-fg-color: $secondary-fg-color; -$message-body-panel-icon-bg-color: $primary-bg-color; +$message-body-panel-icon-bg-color: #F4F6FA; // These two don't change between themes. They are the $warning-color, but we don't // want custom themes to affect them by accident. diff --git a/res/css/views/messages/_MVoiceMessageBody.scss b/src/@types/svg.d.ts similarity index 78% rename from res/css/views/messages/_MVoiceMessageBody.scss rename to src/@types/svg.d.ts index 3dfb98f778..96f671c52f 100644 --- a/res/css/views/messages/_MVoiceMessageBody.scss +++ b/src/@types/svg.d.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MVoiceMessageBody { - display: inline-block; // makes the playback controls magically line up +declare module "*.svg" { + const path: string; + export default path; } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 3c34bf6837..af5d2b3019 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -57,7 +57,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; -export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; +export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet', 'matrix']; const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; @@ -79,8 +79,8 @@ function mightContainEmoji(str: string): boolean { * @return {String} The shortcode (such as :thumbup:) */ export function unicodeToShortcode(char: string): string { - const shortcodes = getEmojiFromUnicode(char).shortcodes; - return shortcodes.length > 0 ? `:${shortcodes[0]}:` : ''; + const shortcodes = getEmojiFromUnicode(char)?.shortcodes; + return shortcodes?.length ? `:${shortcodes[0]}:` : ''; } export function processHtmlForSending(html: string): string { diff --git a/src/UserAddress.ts b/src/UserAddress.ts index a2c546deb7..248814aa01 100644 --- a/src/UserAddress.ts +++ b/src/UserAddress.ts @@ -14,35 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ -import PropTypes from "prop-types"; - const emailRegex = /^\S+@\S+\.\S+$/; const mxUserIdRegex = /^@\S+:\S+$/; const mxRoomIdRegex = /^!\S+:\S+$/; -export const addressTypes = ['mx-user-id', 'mx-room-id', 'email']; - export enum AddressType { Email = "email", MatrixUserId = "mx-user-id", MatrixRoomId = "mx-room-id", } +export const addressTypes = [AddressType.Email, AddressType.MatrixRoomId, AddressType.MatrixUserId]; + // PropType definition for an object describing // an address that can be invited to a room (which // could be a third party identifier or a matrix ID) // along with some additional information about the // address / target. -export const UserAddressType = PropTypes.shape({ - addressType: PropTypes.oneOf(addressTypes).isRequired, - address: PropTypes.string.isRequired, - displayName: PropTypes.string, - avatarMxc: PropTypes.string, +export interface IUserAddress { + addressType: AddressType; + address: string; + displayName?: string; + avatarMxc?: string; // true if the address is known to be a valid address (eg. is a real // user we've seen) or false otherwise (eg. is just an address the // user has entered) - isKnown: PropTypes.bool, -}); + isKnown?: boolean; +} export function getAddressType(inputText: string): AddressType | null { if (emailRegex.test(inputText)) { diff --git a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.tsx similarity index 77% rename from src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js rename to src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.tsx index e1c2b7b202..4d8f5e5663 100644 --- a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.tsx @@ -15,8 +15,10 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../../index'; -import PropTypes from 'prop-types'; + +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import Spinner from "../../../../components/views/elements/Spinner"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from '../../../../languageHandler'; @@ -24,46 +26,44 @@ import SettingsStore from "../../../../settings/SettingsStore"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; import { Action } from "../../../../dispatcher/actions"; import { SettingLevel } from "../../../../settings/SettingLevel"; +interface IProps { + onFinished: (success: boolean) => void; +} + +interface IState { + disabling: boolean; +} /* * Allows the user to disable the Event Index. */ -export default class DisableEventIndexDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - } - - constructor(props) { +export default class DisableEventIndexDialog extends React.Component { + constructor(props: IProps) { super(props); - this.state = { disabling: false, }; } - _onDisable = async () => { + private onDisable = async (): Promise => { this.setState({ disabling: true, }); await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false); await EventIndexPeg.deleteEventIndex(); - this.props.onFinished(); + this.props.onFinished(true); dis.fire(Action.ViewUserSettings); - } - - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const Spinner = sdk.getComponent('elements.Spinner'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + }; + public render(): React.ReactNode { return ( { _t("If disabled, messages from encrypted rooms won't appear in search results.") } { this.state.disabling ? :
} { - Modal.createTrackedDialogAsync("Disable message search", "Disable message search", - import("./DisableEventIndexDialog"), + const DisableEventIndexDialog = (await import("./DisableEventIndexDialog")).default; + Modal.createTrackedDialog("Disable message search", "Disable message search", + DisableEventIndexDialog, null, null, /* priority = */ false, /* static = */ true, ); }; diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 514cf4db09..39ede68a75 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -236,6 +236,8 @@ export default class MessagePanel extends React.Component { // A map of private callEventGroupers = new Map(); + private membersCount = 0; + constructor(props, context) { super(props, context); @@ -256,11 +258,14 @@ export default class MessagePanel extends React.Component { } componentDidMount() { + this.calculateRoomMembersCount(); + this.props.room?.on("RoomState.members", this.calculateRoomMembersCount); this.isMounted = true; } componentWillUnmount() { this.isMounted = false; + this.props.room?.off("RoomState.members", this.calculateRoomMembersCount); SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef); } @@ -274,6 +279,10 @@ export default class MessagePanel extends React.Component { } } + private calculateRoomMembersCount = (): void => { + this.membersCount = this.props.room?.getMembers().length || 0; + }; + private onShowTypingNotificationsChange = (): void => { this.setState({ showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), @@ -711,7 +720,6 @@ export default class MessagePanel extends React.Component { isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId(); const callEventGrouper = this.callEventGroupers.get(mxEv.getContent().call_id); - // use txnId as key if available so that we don't remount during sending ret.push( @@ -743,7 +751,7 @@ export default class MessagePanel extends React.Component { enableFlair={this.props.enableFlair} showReadReceipts={this.props.showReadReceipts} callEventGrouper={callEventGrouper} - hideSender={this.props.room.getMembers().length <= 2 && this.props.layout === Layout.Bubble} + hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble} /> , ); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 7860e65362..1eb958fa6c 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -166,6 +166,10 @@ export interface IState { canReply: boolean; layout: Layout; lowBandwidth: boolean; + alwaysShowTimestamps: boolean; + showTwelveHourTimestamps: boolean; + readMarkerInViewThresholdMs: number; + readMarkerOutOfViewThresholdMs: number; showHiddenEventsInTimeline: boolean; showReadReceipts: boolean; showRedactions: boolean; @@ -231,6 +235,10 @@ export default class RoomView extends React.Component { canReply: false, layout: SettingsStore.getValue("layout"), lowBandwidth: SettingsStore.getValue("lowBandwidth"), + alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"), + showTwelveHourTimestamps: SettingsStore.getValue("showTwelveHourTimestamps"), + readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"), + readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"), showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"), showReadReceipts: true, showRedactions: true, @@ -263,14 +271,26 @@ export default class RoomView extends React.Component { WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); this.settingWatchers = [ - SettingsStore.watchSetting("layout", null, () => - this.setState({ layout: SettingsStore.getValue("layout") }), + SettingsStore.watchSetting("layout", null, (...[,,, value]) => + this.setState({ layout: value as Layout }), ), - SettingsStore.watchSetting("lowBandwidth", null, () => - this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }), + SettingsStore.watchSetting("lowBandwidth", null, (...[,,, value]) => + this.setState({ lowBandwidth: value as boolean }), ), - SettingsStore.watchSetting("showHiddenEventsInTimeline", null, () => - this.setState({ showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline") }), + SettingsStore.watchSetting("alwaysShowTimestamps", null, (...[,,, value]) => + this.setState({ alwaysShowTimestamps: value as boolean }), + ), + SettingsStore.watchSetting("showTwelveHourTimestamps", null, (...[,,, value]) => + this.setState({ showTwelveHourTimestamps: value as boolean }), + ), + SettingsStore.watchSetting("readMarkerInViewThresholdMs", null, (...[,,, value]) => + this.setState({ readMarkerInViewThresholdMs: value as number }), + ), + SettingsStore.watchSetting("readMarkerOutOfViewThresholdMs", null, (...[,,, value]) => + this.setState({ readMarkerOutOfViewThresholdMs: value as number }), + ), + SettingsStore.watchSetting("showHiddenEventsInTimeline", null, (...[,,, value]) => + this.setState({ showHiddenEventsInTimeline: value as boolean }), ), ]; } @@ -337,30 +357,20 @@ export default class RoomView extends React.Component { // Add watchers for each of the settings we just looked up this.settingWatchers = this.settingWatchers.concat([ - SettingsStore.watchSetting("showReadReceipts", null, () => - this.setState({ - showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), - }), + SettingsStore.watchSetting("showReadReceipts", roomId, (...[,,, value]) => + this.setState({ showReadReceipts: value as boolean }), ), - SettingsStore.watchSetting("showRedactions", null, () => - this.setState({ - showRedactions: SettingsStore.getValue("showRedactions", roomId), - }), + SettingsStore.watchSetting("showRedactions", roomId, (...[,,, value]) => + this.setState({ showRedactions: value as boolean }), ), - SettingsStore.watchSetting("showJoinLeaves", null, () => - this.setState({ - showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), - }), + SettingsStore.watchSetting("showJoinLeaves", roomId, (...[,,, value]) => + this.setState({ showJoinLeaves: value as boolean }), ), - SettingsStore.watchSetting("showAvatarChanges", null, () => - this.setState({ - showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), - }), + SettingsStore.watchSetting("showAvatarChanges", roomId, (...[,,, value]) => + this.setState({ showAvatarChanges: value as boolean }), ), - SettingsStore.watchSetting("showDisplaynameChanges", null, () => - this.setState({ - showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), - }), + SettingsStore.watchSetting("showDisplaynameChanges", roomId, (...[,,, value]) => + this.setState({ showDisplaynameChanges: value as boolean }), ), ]); diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index c4210c68a8..0899b1c72a 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -665,8 +665,8 @@ class TimelinePanel extends React.Component { private readMarkerTimeout(readMarkerPosition: number): number { return readMarkerPosition === 0 ? - this.state.readMarkerInViewThresholdMs : - this.state.readMarkerOutOfViewThresholdMs; + this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs : + this.context?.readMarkerOutOfViewThresholdMs ?? this.state.readMarkerOutOfViewThresholdMs; } private async updateReadMarkerOnUserActivity(): Promise { @@ -1493,8 +1493,12 @@ class TimelinePanel extends React.Component { onUserScroll={this.props.onUserScroll} onFillRequest={this.onMessageListFillRequest} onUnfillRequest={this.onMessageListUnfillRequest} - isTwelveHour={this.state.isTwelveHour} - alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.alwaysShowTimestamps} + isTwelveHour={this.context?.showTwelveHourTimestamps ?? this.state.isTwelveHour} + alwaysShowTimestamps={ + this.props.alwaysShowTimestamps ?? + this.context?.alwaysShowTimestamps ?? + this.state.alwaysShowTimestamps + } className={this.props.className} tileShape={this.props.tileShape} resizeNotifier={this.props.resizeNotifier} diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx index 748b1c9ffc..fb9270765e 100644 --- a/src/components/views/audio_messages/AudioPlayer.tsx +++ b/src/components/views/audio_messages/AudioPlayer.tsx @@ -36,6 +36,7 @@ interface IProps { interface IState { playbackPhase: PlaybackState; + error?: boolean; } @replaceableComponent("views.audio_messages.AudioPlayer") @@ -55,8 +56,10 @@ export default class AudioPlayer extends React.PureComponent { // Don't wait for the promise to complete - it will emit a progress update when it // is done, and it's not meant to take long anyhow. - // noinspection JSIgnoredPromiseFromCall - this.props.playback.prepare(); + this.props.playback.prepare().catch(e => { + console.error("Error processing audio file:", e); + this.setState({ error: true }); + }); } private onPlaybackUpdate = (ev: PlaybackState) => { @@ -91,34 +94,37 @@ export default class AudioPlayer extends React.PureComponent { public render(): ReactNode { // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard // events for accessibility - return
-
- -
- - { this.props.mediaName || _t("Unnamed audio") } - -
- -   { /* easiest way to introduce a gap between the components */ } - { this.renderFileSize() } + return <> +
+
+ +
+ + { this.props.mediaName || _t("Unnamed audio") } + +
+ +   { /* easiest way to introduce a gap between the components */ } + { this.renderFileSize() } +
+
+ + +
-
- - -
-
; + { this.state.error &&
{ _t("Error downloading audio") }
} + ; } } diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx index 7d9312f369..ca0ed83d84 100644 --- a/src/components/views/audio_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -22,6 +22,7 @@ import PlaybackClock from "./PlaybackClock"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { TileShape } from "../rooms/EventTile"; import PlaybackWaveform from "./PlaybackWaveform"; +import { _t } from "../../../languageHandler"; interface IProps { // Playback instance to render. Cannot change during component lifecycle: create @@ -33,6 +34,7 @@ interface IProps { interface IState { playbackPhase: PlaybackState; + error?: boolean; } @replaceableComponent("views.audio_messages.RecordingPlayback") @@ -49,8 +51,10 @@ export default class RecordingPlayback extends React.PureComponent { + console.error("Error processing audio file:", e); + this.setState({ error: true }); + }); } private get isWaveformable(): boolean { @@ -65,10 +69,13 @@ export default class RecordingPlayback extends React.PureComponent - - - { this.isWaveformable && } -
; + return <> +
+ + + { this.isWaveformable && } +
+ { this.state.error &&
{ _t("Error downloading audio") }
} + ; } } diff --git a/src/components/views/auth/AuthBody.js b/src/components/views/auth/AuthBody.tsx similarity index 95% rename from src/components/views/auth/AuthBody.js rename to src/components/views/auth/AuthBody.tsx index abe7fd2fd3..3543a573d7 100644 --- a/src/components/views/auth/AuthBody.js +++ b/src/components/views/auth/AuthBody.tsx @@ -19,7 +19,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthBody") export default class AuthBody extends React.PureComponent { - render() { + public render(): React.ReactNode { return
{ this.props.children }
; diff --git a/src/components/views/auth/AuthFooter.js b/src/components/views/auth/AuthFooter.tsx similarity index 96% rename from src/components/views/auth/AuthFooter.js rename to src/components/views/auth/AuthFooter.tsx index e81d2cd969..00bced8c39 100644 --- a/src/components/views/auth/AuthFooter.js +++ b/src/components/views/auth/AuthFooter.tsx @@ -22,7 +22,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthFooter") export default class AuthFooter extends React.Component { - render() { + public render(): React.ReactNode { return (
{ _t("powered by Matrix") } diff --git a/src/components/views/auth/AuthHeader.js b/src/components/views/auth/AuthHeader.tsx similarity index 71% rename from src/components/views/auth/AuthHeader.js rename to src/components/views/auth/AuthHeader.tsx index d9bd81adcb..cab7da1468 100644 --- a/src/components/views/auth/AuthHeader.js +++ b/src/components/views/auth/AuthHeader.tsx @@ -16,20 +16,17 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AuthHeaderLogo from "./AuthHeaderLogo"; +import LanguageSelector from "./LanguageSelector"; + +interface IProps { + disableLanguageSelector?: boolean; +} @replaceableComponent("views.auth.AuthHeader") -export default class AuthHeader extends React.Component { - static propTypes = { - disableLanguageSelector: PropTypes.bool, - }; - - render() { - const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo'); - const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector'); - +export default class AuthHeader extends React.Component { + public render(): React.ReactNode { return (
diff --git a/src/components/views/auth/AuthHeaderLogo.js b/src/components/views/auth/AuthHeaderLogo.tsx similarity index 95% rename from src/components/views/auth/AuthHeaderLogo.js rename to src/components/views/auth/AuthHeaderLogo.tsx index 0adf18dc1c..b6724793a5 100644 --- a/src/components/views/auth/AuthHeaderLogo.js +++ b/src/components/views/auth/AuthHeaderLogo.tsx @@ -19,7 +19,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthHeaderLogo") export default class AuthHeaderLogo extends React.PureComponent { - render() { + public render(): React.ReactNode { return
Matrix
; diff --git a/src/components/views/auth/AuthPage.js b/src/components/views/auth/AuthPage.tsx similarity index 90% rename from src/components/views/auth/AuthPage.js rename to src/components/views/auth/AuthPage.tsx index 6a73b06c16..c402d5b699 100644 --- a/src/components/views/auth/AuthPage.js +++ b/src/components/views/auth/AuthPage.tsx @@ -17,14 +17,12 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AuthFooter from "./AuthFooter"; @replaceableComponent("views.auth.AuthPage") export default class AuthPage extends React.PureComponent { - render() { - const AuthFooter = sdk.getComponent('auth.AuthFooter'); - + public render(): React.ReactNode { return (
diff --git a/src/components/views/auth/CompleteSecurityBody.js b/src/components/views/auth/CompleteSecurityBody.tsx similarity index 95% rename from src/components/views/auth/CompleteSecurityBody.js rename to src/components/views/auth/CompleteSecurityBody.tsx index 745d7abbf2..8f6affb64e 100644 --- a/src/components/views/auth/CompleteSecurityBody.js +++ b/src/components/views/auth/CompleteSecurityBody.tsx @@ -19,7 +19,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.CompleteSecurityBody") export default class CompleteSecurityBody extends React.PureComponent { - render() { + public render(): React.ReactNode { return
{ this.props.children }
; diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.tsx similarity index 75% rename from src/components/views/auth/CountryDropdown.js rename to src/components/views/auth/CountryDropdown.tsx index cbc19e0f8d..eb5b27be9d 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.tsx @@ -15,21 +15,19 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; - -import { COUNTRIES, getEmojiFlag } from '../../../phonenumber'; +import { COUNTRIES, getEmojiFlag, PhoneNumberCountryDefinition } from '../../../phonenumber'; import SdkConfig from "../../../SdkConfig"; import { _t } from "../../../languageHandler"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Dropdown from "../elements/Dropdown"; const COUNTRIES_BY_ISO2 = {}; for (const c of COUNTRIES) { COUNTRIES_BY_ISO2[c.iso2] = c; } -function countryMatchesSearchQuery(query, country) { +function countryMatchesSearchQuery(query: string, country: PhoneNumberCountryDefinition): boolean { // Remove '+' if present (when searching for a prefix) if (query[0] === '+') { query = query.slice(1); @@ -41,15 +39,26 @@ function countryMatchesSearchQuery(query, country) { return false; } -@replaceableComponent("views.auth.CountryDropdown") -export default class CountryDropdown extends React.Component { - constructor(props) { - super(props); - this._onSearchChange = this._onSearchChange.bind(this); - this._onOptionChange = this._onOptionChange.bind(this); - this._getShortOption = this._getShortOption.bind(this); +interface IProps { + value?: string; + onOptionChange: (country: PhoneNumberCountryDefinition) => void; + isSmall: boolean; // if isSmall, show +44 in the selected value + showPrefix: boolean; + className?: string; + disabled?: boolean; +} - let defaultCountry = COUNTRIES[0]; +interface IState { + searchQuery: string; + defaultCountry: PhoneNumberCountryDefinition; +} + +@replaceableComponent("views.auth.CountryDropdown") +export default class CountryDropdown extends React.Component { + constructor(props: IProps) { + super(props); + + let defaultCountry: PhoneNumberCountryDefinition = COUNTRIES[0]; const defaultCountryCode = SdkConfig.get()["defaultCountryCode"]; if (defaultCountryCode) { const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase()); @@ -62,7 +71,7 @@ export default class CountryDropdown extends React.Component { }; } - componentDidMount() { + public componentDidMount(): void { if (!this.props.value) { // If no value is given, we start with the default // country selected, but our parent component @@ -71,21 +80,21 @@ export default class CountryDropdown extends React.Component { } } - _onSearchChange(search) { + private onSearchChange = (search: string): void => { this.setState({ searchQuery: search, }); - } + }; - _onOptionChange(iso2) { + private onOptionChange = (iso2: string): void => { this.props.onOptionChange(COUNTRIES_BY_ISO2[iso2]); - } + }; - _flagImgForIso2(iso2) { + private flagImgForIso2(iso2: string): React.ReactNode { return
{ getEmojiFlag(iso2) }
; } - _getShortOption(iso2) { + private getShortOption = (iso2: string): React.ReactNode => { if (!this.props.isSmall) { return undefined; } @@ -94,14 +103,12 @@ export default class CountryDropdown extends React.Component { countryPrefix = '+' + COUNTRIES_BY_ISO2[iso2].prefix; } return - { this._flagImgForIso2(iso2) } + { this.flagImgForIso2(iso2) } { countryPrefix } ; - } - - render() { - const Dropdown = sdk.getComponent('elements.Dropdown'); + }; + public render(): React.ReactNode { let displayedCountries; if (this.state.searchQuery) { displayedCountries = COUNTRIES.filter( @@ -124,7 +131,7 @@ export default class CountryDropdown extends React.Component { const options = displayedCountries.map((country) => { return
- { this._flagImgForIso2(country.iso2) } + { this.flagImgForIso2(country.iso2) } { _t(country.name) } (+{ country.prefix })
; }); @@ -136,10 +143,10 @@ export default class CountryDropdown extends React.Component { return ; } } - -CountryDropdown.propTypes = { - className: PropTypes.string, - isSmall: PropTypes.bool, - // if isSmall, show +44 in the selected value - showPrefix: PropTypes.bool, - onOptionChange: PropTypes.func.isRequired, - value: PropTypes.string, - disabled: PropTypes.bool, -}; diff --git a/src/components/views/auth/LanguageSelector.js b/src/components/views/auth/LanguageSelector.tsx similarity index 85% rename from src/components/views/auth/LanguageSelector.js rename to src/components/views/auth/LanguageSelector.tsx index 88293310e7..c26b4797f3 100644 --- a/src/components/views/auth/LanguageSelector.js +++ b/src/components/views/auth/LanguageSelector.tsx @@ -18,21 +18,23 @@ import SdkConfig from "../../../SdkConfig"; import { getCurrentLanguage } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import PlatformPeg from "../../../PlatformPeg"; -import * as sdk from '../../../index'; import React from 'react'; import { SettingLevel } from "../../../settings/SettingLevel"; +import LanguageDropdown from "../elements/LanguageDropdown"; -function onChange(newLang) { +function onChange(newLang: string): void { if (getCurrentLanguage() !== newLang) { SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang); PlatformPeg.get().reload(); } } -export default function LanguageSelector({ disabled }) { - if (SdkConfig.get()['disable_login_language_selector']) return
; +interface IProps { + disabled?: boolean; +} - const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown'); +export default function LanguageSelector({ disabled }: IProps): JSX.Element { + if (SdkConfig.get()['disable_login_language_selector']) return
; return { + constructor(props: IProps) { super(props); CountlyAnalytics.instance.track("onboarding_welcome"); } - render() { - const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); - const LanguageSelector = sdk.getComponent('auth.LanguageSelector'); + public render(): React.ReactNode { + // FIXME: Using an import will result in wrench-element-tests failures + const EmbeddedPage = sdk.getComponent("structures.EmbeddedPage"); const pagesConfig = SdkConfig.get().embeddedPages; let pageUrl = null; diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.tsx similarity index 79% rename from src/components/views/dialogs/AddressPickerDialog.js rename to src/components/views/dialogs/AddressPickerDialog.tsx index 61215c33a1..1a976918bd 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.tsx @@ -18,14 +18,12 @@ limitations under the License. */ import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; import { sleep } from "matrix-js-sdk/src/utils"; import { _t, _td } from '../../../languageHandler'; -import * as sdk from '../../../index'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import dis from '../../../dispatcher/dispatcher'; -import { addressTypes, getAddressType } from '../../../UserAddress'; +import { AddressType, addressTypes, getAddressType, IUserAddress } from '../../../UserAddress'; import GroupStore from '../../../stores/GroupStore'; import * as Email from '../../../email'; import IdentityAuthClient from '../../../IdentityAuthClient'; @@ -34,6 +32,10 @@ import { abbreviateUrl } from '../../../utils/UrlUtils'; import { Key } from "../../../Keyboard"; import { Action } from "../../../dispatcher/actions"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AddressSelector from '../elements/AddressSelector'; +import AddressTile from '../elements/AddressTile'; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; const TRUNCATE_QUERY_LIST = 40; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; @@ -44,29 +46,64 @@ const addressTypeName = { 'email': _td("email address"), }; -@replaceableComponent("views.dialogs.AddressPickerDialog") -export default class AddressPickerDialog extends React.Component { - static propTypes = { - title: PropTypes.string.isRequired, - description: PropTypes.node, - // Extra node inserted after picker input, dropdown and errors - extraNode: PropTypes.node, - value: PropTypes.string, - placeholder: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), - roomId: PropTypes.string, - button: PropTypes.string, - focus: PropTypes.bool, - validAddressTypes: PropTypes.arrayOf(PropTypes.oneOf(addressTypes)), - onFinished: PropTypes.func.isRequired, - groupId: PropTypes.string, - // The type of entity to search for. Default: 'user'. - pickerType: PropTypes.oneOf(['user', 'room']), - // Whether the current user should be included in the addresses returned. Only - // applicable when pickerType is `user`. Default: false. - includeSelf: PropTypes.bool, - }; +interface IResult { + user_id: string; // eslint-disable-line camelcase + room_id?: string; // eslint-disable-line camelcase + name?: string; + display_name?: string; // eslint-disable-line camelcase + avatar_url?: string;// eslint-disable-line camelcase +} - static defaultProps = { +interface IProps { + title: string; + description?: JSX.Element; + // Extra node inserted after picker input, dropdown and errors + extraNode?: JSX.Element; + value?: string; + placeholder?: ((validAddressTypes: any) => string) | string; + roomId?: string; + button?: string; + focus?: boolean; + validAddressTypes?: AddressType[]; + onFinished: (success: boolean, list?: IUserAddress[]) => void; + groupId?: string; + // The type of entity to search for. Default: 'user'. + pickerType?: 'user' | 'room'; + // Whether the current user should be included in the addresses returned. Only + // applicable when pickerType is `user`. Default: false. + includeSelf?: boolean; +} + +interface IState { + // Whether to show an error message because of an invalid address + invalidAddressError: boolean; + // List of UserAddressType objects representing + // the list of addresses we're going to invite + selectedList: IUserAddress[]; + // Whether a search is ongoing + busy: boolean; + // An error message generated during the user directory search + searchError: string; + // Whether the server supports the user_directory API + serverSupportsUserDirectory: boolean; + // The query being searched for + query: string; + // List of UserAddressType objects representing the set of + // auto-completion results for the current search query. + suggestedList: IUserAddress[]; + // List of address types initialised from props, but may change while the + // dialog is open and represents the supported list of address types at this time. + validAddressTypes: AddressType[]; +} + +@replaceableComponent("views.dialogs.AddressPickerDialog") +export default class AddressPickerDialog extends React.Component { + private textinput = createRef(); + private addressSelector = createRef(); + private queryChangedDebouncer: number; + private cancelThreepidLookup: () => void; + + static defaultProps: Partial = { value: "", focus: true, validAddressTypes: addressTypes, @@ -74,36 +111,23 @@ export default class AddressPickerDialog extends React.Component { includeSelf: false, }; - constructor(props) { + constructor(props: IProps) { super(props); - this._textinput = createRef(); - let validAddressTypes = this.props.validAddressTypes; // Remove email from validAddressTypes if no IS is configured. It may be added at a later stage by the user - if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes("email")) { - validAddressTypes = validAddressTypes.filter(type => type !== "email"); + if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes(AddressType.Email)) { + validAddressTypes = validAddressTypes.filter(type => type !== AddressType.Email); } this.state = { - // Whether to show an error message because of an invalid address invalidAddressError: false, - // List of UserAddressType objects representing - // the list of addresses we're going to invite selectedList: [], - // Whether a search is ongoing busy: false, - // An error message generated during the user directory search searchError: null, - // Whether the server supports the user_directory API serverSupportsUserDirectory: true, - // The query being searched for query: "", - // List of UserAddressType objects representing the set of - // auto-completion results for the current search query. suggestedList: [], - // List of address types initialised from props, but may change while the - // dialog is open and represents the supported list of address types at this time. validAddressTypes, }; } @@ -111,11 +135,11 @@ export default class AddressPickerDialog extends React.Component { componentDidMount() { if (this.props.focus) { // Set the cursor at the end of the text input - this._textinput.current.value = this.props.value; + this.textinput.current.value = this.props.value; } } - getPlaceholder() { + private getPlaceholder(): string { const { placeholder } = this.props; if (typeof placeholder === "string") { return placeholder; @@ -124,23 +148,23 @@ export default class AddressPickerDialog extends React.Component { return placeholder(this.state.validAddressTypes); } - onButtonClick = () => { + private onButtonClick = (): void => { let selectedList = this.state.selectedList.slice(); // Check the text input field to see if user has an unconverted address // If there is and it's valid add it to the local selectedList - if (this._textinput.current.value !== '') { - selectedList = this._addAddressesToList([this._textinput.current.value]); + if (this.textinput.current.value !== '') { + selectedList = this.addAddressesToList([this.textinput.current.value]); if (selectedList === null) return; } this.props.onFinished(true, selectedList); }; - onCancel = () => { + private onCancel = (): void => { this.props.onFinished(false); }; - onKeyDown = e => { - const textInput = this._textinput.current ? this._textinput.current.value : undefined; + private onKeyDown = (e: React.KeyboardEvent): void => { + const textInput = this.textinput.current ? this.textinput.current.value : undefined; if (e.key === Key.ESCAPE) { e.stopPropagation(); @@ -149,15 +173,15 @@ export default class AddressPickerDialog extends React.Component { } else if (e.key === Key.ARROW_UP) { e.stopPropagation(); e.preventDefault(); - if (this.addressSelector) this.addressSelector.moveSelectionUp(); + if (this.addressSelector.current) this.addressSelector.current.moveSelectionUp(); } else if (e.key === Key.ARROW_DOWN) { e.stopPropagation(); e.preventDefault(); - if (this.addressSelector) this.addressSelector.moveSelectionDown(); + if (this.addressSelector.current) this.addressSelector.current.moveSelectionDown(); } else if (this.state.suggestedList.length > 0 && [Key.COMMA, Key.ENTER, Key.TAB].includes(e.key)) { e.stopPropagation(); e.preventDefault(); - if (this.addressSelector) this.addressSelector.chooseSelection(); + if (this.addressSelector.current) this.addressSelector.current.chooseSelection(); } else if (textInput.length === 0 && this.state.selectedList.length && e.key === Key.BACKSPACE) { e.stopPropagation(); e.preventDefault(); @@ -169,17 +193,17 @@ export default class AddressPickerDialog extends React.Component { // if there's nothing in the input box, submit the form this.onButtonClick(); } else { - this._addAddressesToList([textInput]); + this.addAddressesToList([textInput]); } } else if (textInput && (e.key === Key.COMMA || e.key === Key.TAB)) { e.stopPropagation(); e.preventDefault(); - this._addAddressesToList([textInput]); + this.addAddressesToList([textInput]); } }; - onQueryChanged = ev => { - const query = ev.target.value; + private onQueryChanged = (ev: React.ChangeEvent): void => { + const query = (ev.target as HTMLTextAreaElement).value; if (this.queryChangedDebouncer) { clearTimeout(this.queryChangedDebouncer); } @@ -188,17 +212,17 @@ export default class AddressPickerDialog extends React.Component { this.queryChangedDebouncer = setTimeout(() => { if (this.props.pickerType === 'user') { if (this.props.groupId) { - this._doNaiveGroupSearch(query); + this.doNaiveGroupSearch(query); } else if (this.state.serverSupportsUserDirectory) { - this._doUserDirectorySearch(query); + this.doUserDirectorySearch(query); } else { - this._doLocalSearch(query); + this.doLocalSearch(query); } } else if (this.props.pickerType === 'room') { if (this.props.groupId) { - this._doNaiveGroupRoomSearch(query); + this.doNaiveGroupRoomSearch(query); } else { - this._doRoomSearch(query); + this.doRoomSearch(query); } } else { console.error('Unknown pickerType', this.props.pickerType); @@ -213,7 +237,7 @@ export default class AddressPickerDialog extends React.Component { } }; - onDismissed = index => () => { + private onDismissed = (index: number) => () => { const selectedList = this.state.selectedList.slice(); selectedList.splice(index, 1); this.setState({ @@ -221,25 +245,21 @@ export default class AddressPickerDialog extends React.Component { suggestedList: [], query: "", }); - if (this._cancelThreepidLookup) this._cancelThreepidLookup(); + if (this.cancelThreepidLookup) this.cancelThreepidLookup(); }; - onClick = index => () => { - this.onSelected(index); - }; - - onSelected = index => { + private onSelected = (index: number): void => { const selectedList = this.state.selectedList.slice(); - selectedList.push(this._getFilteredSuggestions()[index]); + selectedList.push(this.getFilteredSuggestions()[index]); this.setState({ selectedList, suggestedList: [], query: "", }); - if (this._cancelThreepidLookup) this._cancelThreepidLookup(); + if (this.cancelThreepidLookup) this.cancelThreepidLookup(); }; - _doNaiveGroupSearch(query) { + private doNaiveGroupSearch(query: string): void { const lowerCaseQuery = query.toLowerCase(); this.setState({ busy: true, @@ -260,7 +280,7 @@ export default class AddressPickerDialog extends React.Component { display_name: u.displayname, }); }); - this._processResults(results, query); + this.processResults(results, query); }).catch((err) => { console.error('Error whilst searching group rooms: ', err); this.setState({ @@ -273,7 +293,7 @@ export default class AddressPickerDialog extends React.Component { }); } - _doNaiveGroupRoomSearch(query) { + private doNaiveGroupRoomSearch(query: string): void { const lowerCaseQuery = query.toLowerCase(); const results = []; GroupStore.getGroupRooms(this.props.groupId).forEach((r) => { @@ -289,13 +309,13 @@ export default class AddressPickerDialog extends React.Component { name: r.name || r.canonical_alias, }); }); - this._processResults(results, query); + this.processResults(results, query); this.setState({ busy: false, }); } - _doRoomSearch(query) { + private doRoomSearch(query: string): void { const lowerCaseQuery = query.toLowerCase(); const rooms = MatrixClientPeg.get().getRooms(); const results = []; @@ -346,13 +366,13 @@ export default class AddressPickerDialog extends React.Component { return a.rank - b.rank; }); - this._processResults(sortedResults, query); + this.processResults(sortedResults, query); this.setState({ busy: false, }); } - _doUserDirectorySearch(query) { + private doUserDirectorySearch(query: string): void { this.setState({ busy: true, query, @@ -366,7 +386,7 @@ export default class AddressPickerDialog extends React.Component { if (this.state.query !== query) { return; } - this._processResults(resp.results, query); + this.processResults(resp.results, query); }).catch((err) => { console.error('Error whilst searching user directory: ', err); this.setState({ @@ -377,7 +397,7 @@ export default class AddressPickerDialog extends React.Component { serverSupportsUserDirectory: false, }); // Do a local search immediately - this._doLocalSearch(query); + this.doLocalSearch(query); } }).then(() => { this.setState({ @@ -386,7 +406,7 @@ export default class AddressPickerDialog extends React.Component { }); } - _doLocalSearch(query) { + private doLocalSearch(query: string): void { this.setState({ query, searchError: null, @@ -407,10 +427,10 @@ export default class AddressPickerDialog extends React.Component { avatar_url: user.avatarUrl, }); }); - this._processResults(results, query); + this.processResults(results, query); } - _processResults(results, query) { + private processResults(results: IResult[], query: string): void { const suggestedList = []; results.forEach((result) => { if (result.room_id) { @@ -465,27 +485,27 @@ export default class AddressPickerDialog extends React.Component { address: query, isKnown: false, }); - if (this._cancelThreepidLookup) this._cancelThreepidLookup(); + if (this.cancelThreepidLookup) this.cancelThreepidLookup(); if (addrType === 'email') { - this._lookupThreepid(addrType, query); + this.lookupThreepid(addrType, query); } } this.setState({ suggestedList, invalidAddressError: false, }, () => { - if (this.addressSelector) this.addressSelector.moveSelectionTop(); + if (this.addressSelector.current) this.addressSelector.current.moveSelectionTop(); }); } - _addAddressesToList(addressTexts) { + private addAddressesToList(addressTexts: string[]): IUserAddress[] { const selectedList = this.state.selectedList.slice(); let hasError = false; addressTexts.forEach((addressText) => { addressText = addressText.trim(); const addrType = getAddressType(addressText); - const addrObj = { + const addrObj: IUserAddress = { addressType: addrType, address: addressText, isKnown: false, @@ -504,7 +524,6 @@ export default class AddressPickerDialog extends React.Component { const room = MatrixClientPeg.get().getRoom(addrObj.address); if (room) { addrObj.displayName = room.name; - addrObj.avatarMxc = room.avatarUrl; addrObj.isKnown = true; } } @@ -518,17 +537,17 @@ export default class AddressPickerDialog extends React.Component { query: "", invalidAddressError: hasError ? true : this.state.invalidAddressError, }); - if (this._cancelThreepidLookup) this._cancelThreepidLookup(); + if (this.cancelThreepidLookup) this.cancelThreepidLookup(); return hasError ? null : selectedList; } - async _lookupThreepid(medium, address) { + private async lookupThreepid(medium: AddressType, address: string): Promise { let cancelled = false; // Note that we can't safely remove this after we're done // because we don't know that it's the same one, so we just // leave it: it's replacing the old one each time so it's // not like they leak. - this._cancelThreepidLookup = function() { + this.cancelThreepidLookup = function() { cancelled = true; }; @@ -570,7 +589,7 @@ export default class AddressPickerDialog extends React.Component { } } - _getFilteredSuggestions() { + private getFilteredSuggestions(): IUserAddress[] { // map addressType => set of addresses to avoid O(n*m) operation const selectedAddresses = {}; this.state.selectedList.forEach(({ address, addressType }) => { @@ -584,15 +603,15 @@ export default class AddressPickerDialog extends React.Component { }); } - _onPaste = e => { + private onPaste = (e: React.ClipboardEvent): void => { // Prevent the text being pasted into the textarea e.preventDefault(); const text = e.clipboardData.getData("text"); // Process it as a list of addresses to add instead - this._addAddressesToList(text.split(/[\s,]+/)); + this.addAddressesToList(text.split(/[\s,]+/)); }; - onUseDefaultIdentityServerClick = e => { + private onUseDefaultIdentityServerClick = (e: React.MouseEvent): void => { e.preventDefault(); // Update the IS in account data. Actually using it may trigger terms. @@ -601,22 +620,17 @@ export default class AddressPickerDialog extends React.Component { // Add email as a valid address type. const { validAddressTypes } = this.state; - validAddressTypes.push('email'); + validAddressTypes.push(AddressType.Email); this.setState({ validAddressTypes }); }; - onManageSettingsClick = e => { + private onManageSettingsClick = (e: React.MouseEvent): void => { e.preventDefault(); dis.fire(Action.ViewUserSettings); this.onCancel(); }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const AddressSelector = sdk.getComponent("elements.AddressSelector"); - this.scrollElement = null; - let inputLabel; if (this.props.description) { inputLabel =
@@ -627,7 +641,6 @@ export default class AddressPickerDialog extends React.Component { const query = []; // create the invite list if (this.state.selectedList.length > 0) { - const AddressTile = sdk.getComponent("elements.AddressTile"); for (let i = 0; i < this.state.selectedList.length; i++) { query.push( , ); - const filteredSuggestedList = this._getFilteredSuggestions(); + const filteredSuggestedList = this.getFilteredSuggestions(); let error; let addressSelector; @@ -675,7 +688,7 @@ export default class AddressPickerDialog extends React.Component { error =
{ _t("No results") }
; } else { addressSelector = ( - {this.addressSelector = ref;}} + { _t( diff --git a/src/components/views/elements/ActionButton.js b/src/components/views/elements/ActionButton.tsx similarity index 71% rename from src/components/views/elements/ActionButton.js rename to src/components/views/elements/ActionButton.tsx index 9c9e9663e7..390e84be77 100644 --- a/src/components/views/elements/ActionButton.js +++ b/src/components/views/elements/ActionButton.tsx @@ -15,56 +15,62 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import AccessibleButton from './AccessibleButton'; import dis from '../../../dispatcher/dispatcher'; -import * as sdk from '../../../index'; import Analytics from '../../../Analytics'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Tooltip from './Tooltip'; + +interface IProps { + size?: string; + tooltip?: boolean; + action: string; + mouseOverAction?: string; + label: string; + iconPath?: string; + className?: string; + children?: JSX.Element; +} + +interface IState { + showTooltip: boolean; +} @replaceableComponent("views.elements.ActionButton") -export default class ActionButton extends React.Component { - static propTypes = { - size: PropTypes.string, - tooltip: PropTypes.bool, - action: PropTypes.string.isRequired, - mouseOverAction: PropTypes.string, - label: PropTypes.string.isRequired, - iconPath: PropTypes.string, - className: PropTypes.string, - children: PropTypes.node, - }; - - static defaultProps = { +export default class ActionButton extends React.Component { + static defaultProps: Partial = { size: "25", tooltip: false, }; - state = { - showTooltip: false, - }; + constructor(props: IProps) { + super(props); - _onClick = (ev) => { + this.state = { + showTooltip: false, + }; + } + + private onClick = (ev: React.MouseEvent): void => { ev.stopPropagation(); Analytics.trackEvent('Action Button', 'click', this.props.action); dis.dispatch({ action: this.props.action }); }; - _onMouseEnter = () => { + private onMouseEnter = (): void => { if (this.props.tooltip) this.setState({ showTooltip: true }); if (this.props.mouseOverAction) { dis.dispatch({ action: this.props.mouseOverAction }); } }; - _onMouseLeave = () => { + private onMouseLeave = (): void => { this.setState({ showTooltip: false }); }; render() { let tooltip; if (this.state.showTooltip) { - const Tooltip = sdk.getComponent("elements.Tooltip"); tooltip = ; } @@ -80,9 +86,9 @@ export default class ActionButton extends React.Component { return ( { icon } diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.tsx similarity index 68% rename from src/components/views/elements/AddressSelector.js rename to src/components/views/elements/AddressSelector.tsx index b7c9124438..eae82142da 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.tsx @@ -15,30 +15,37 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; +import React, { createRef } from 'react'; import classNames from 'classnames'; -import { UserAddressType } from '../../../UserAddress'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { IUserAddress } from '../../../UserAddress'; +import AddressTile from './AddressTile'; + +interface IProps { + onSelected: (index: number) => void; + + // List of the addresses to display + addressList: IUserAddress[]; + // Whether to show the address on the address tiles + showAddress?: boolean; + truncateAt: number; + selected?: number; + + // Element to put as a header on top of the list + header?: JSX.Element; +} + +interface IState { + selected: number; + hover: boolean; +} @replaceableComponent("views.elements.AddressSelector") -export default class AddressSelector extends React.Component { - static propTypes = { - onSelected: PropTypes.func.isRequired, +export default class AddressSelector extends React.Component { + private scrollElement = createRef(); + private addressListElement = createRef(); - // List of the addresses to display - addressList: PropTypes.arrayOf(UserAddressType).isRequired, - // Whether to show the address on the address tiles - showAddress: PropTypes.bool, - truncateAt: PropTypes.number.isRequired, - selected: PropTypes.number, - - // Element to put as a header on top of the list - header: PropTypes.node, - }; - - constructor(props) { + constructor(props: IProps) { super(props); this.state = { @@ -48,10 +55,10 @@ export default class AddressSelector extends React.Component { } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps(props) { // eslint-disable-line camelcase + UNSAFE_componentWillReceiveProps(props: IProps) { // eslint-disable-line // Make sure the selected item isn't outside the list bounds const selected = this.state.selected; - const maxSelected = this._maxSelected(props.addressList); + const maxSelected = this.maxSelected(props.addressList); if (selected > maxSelected) { this.setState({ selected: maxSelected }); } @@ -60,13 +67,13 @@ export default class AddressSelector extends React.Component { componentDidUpdate() { // As the user scrolls with the arrow keys keep the selected item // at the top of the window. - if (this.scrollElement && this.props.addressList.length > 0 && !this.state.hover) { - const elementHeight = this.addressListElement.getBoundingClientRect().height; - this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight; + if (this.scrollElement.current && this.props.addressList.length > 0 && !this.state.hover) { + const elementHeight = this.addressListElement.current.getBoundingClientRect().height; + this.scrollElement.current.scrollTop = (this.state.selected * elementHeight) - elementHeight; } } - moveSelectionTop = () => { + public moveSelectionTop = (): void => { if (this.state.selected > 0) { this.setState({ selected: 0, @@ -75,7 +82,7 @@ export default class AddressSelector extends React.Component { } }; - moveSelectionUp = () => { + public moveSelectionUp = (): void => { if (this.state.selected > 0) { this.setState({ selected: this.state.selected - 1, @@ -84,8 +91,8 @@ export default class AddressSelector extends React.Component { } }; - moveSelectionDown = () => { - if (this.state.selected < this._maxSelected(this.props.addressList)) { + public moveSelectionDown = (): void => { + if (this.state.selected < this.maxSelected(this.props.addressList)) { this.setState({ selected: this.state.selected + 1, hover: false, @@ -93,26 +100,26 @@ export default class AddressSelector extends React.Component { } }; - chooseSelection = () => { + public chooseSelection = (): void => { this.selectAddress(this.state.selected); }; - onClick = index => { + private onClick = (index: number): void => { this.selectAddress(index); }; - onMouseEnter = index => { + private onMouseEnter = (index: number): void => { this.setState({ selected: index, hover: true, }); }; - onMouseLeave = () => { + private onMouseLeave = (): void => { this.setState({ hover: false }); }; - selectAddress = index => { + private selectAddress = (index: number): void => { // Only try to select an address if one exists if (this.props.addressList.length !== 0) { this.props.onSelected(index); @@ -120,9 +127,8 @@ export default class AddressSelector extends React.Component { } }; - createAddressListTiles() { - const AddressTile = sdk.getComponent("elements.AddressTile"); - const maxSelected = this._maxSelected(this.props.addressList); + private createAddressListTiles(): JSX.Element[] { + const maxSelected = this.maxSelected(this.props.addressList); const addressList = []; // Only create the address elements if there are address @@ -143,14 +149,12 @@ export default class AddressSelector extends React.Component { onMouseEnter={this.onMouseEnter.bind(this, i)} onMouseLeave={this.onMouseLeave} key={this.props.addressList[i].addressType + "/" + this.props.addressList[i].address} - ref={(ref) => { this.addressListElement = ref; }} + ref={this.addressListElement} >
, ); @@ -159,7 +163,7 @@ export default class AddressSelector extends React.Component { return addressList; } - _maxSelected(list) { + private maxSelected(list: IUserAddress[]): number { const listSize = list.length === 0 ? 0 : list.length - 1; const maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize; return maxSelected; @@ -172,7 +176,7 @@ export default class AddressSelector extends React.Component { }); return ( -
{this.scrollElement = ref;}}> +
{ this.props.header } { this.createAddressListTiles() }
diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.tsx similarity index 85% rename from src/components/views/elements/AddressTile.js rename to src/components/views/elements/AddressTile.tsx index ca85d73a11..cdeb7cacd6 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.tsx @@ -16,24 +16,25 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; -import * as sdk from "../../../index"; import { _t } from '../../../languageHandler'; -import { UserAddressType } from '../../../UserAddress'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromMxc } from "../../../customisations/Media"; +import { IUserAddress } from '../../../UserAddress'; +import BaseAvatar from '../avatars/BaseAvatar'; +import EmailUserIcon from "../../../../res/img/icon-email-user.svg"; + +interface IProps { + address: IUserAddress; + canDismiss?: boolean; + onDismissed?: () => void; + justified?: boolean; + showAddress?: boolean; +} @replaceableComponent("views.elements.AddressTile") -export default class AddressTile extends React.Component { - static propTypes = { - address: UserAddressType.isRequired, - canDismiss: PropTypes.bool, - onDismissed: PropTypes.func, - justified: PropTypes.bool, - }; - - static defaultProps = { +export default class AddressTile extends React.Component { + static defaultProps: Partial = { canDismiss: false, onDismissed: function() {}, // NOP justified: false, @@ -49,11 +50,9 @@ export default class AddressTile extends React.Component { if (isMatrixAddress && address.avatarMxc) { imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25)); } else if (address.addressType === 'email') { - imgUrls.push(require("../../../../res/img/icon-email-user.svg")); + imgUrls.push(EmailUserIcon); } - const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - const nameClasses = classNames({ "mx_AddressTile_name": true, "mx_AddressTile_justified": this.props.justified, @@ -70,9 +69,10 @@ export default class AddressTile extends React.Component { info = (
{ name }
- { this.props.showAddress ? -
{ address.address }
: -
+ { + this.props.showAddress + ?
{ address.address }
+ :
}
); diff --git a/src/components/views/elements/AppPermission.js b/src/components/views/elements/AppPermission.tsx similarity index 85% rename from src/components/views/elements/AppPermission.js rename to src/components/views/elements/AppPermission.tsx index a7d249164b..8dc874381a 100644 --- a/src/components/views/elements/AppPermission.js +++ b/src/components/views/elements/AppPermission.tsx @@ -17,30 +17,39 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import url from 'url'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import WidgetUtils from "../../../utils/WidgetUtils"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; +import MemberAvatar from '../avatars/MemberAvatar'; +import BaseAvatar from '../avatars/BaseAvatar'; +import AccessibleButton from './AccessibleButton'; +import TextWithTooltip from "./TextWithTooltip"; + +interface IProps { + url: string; + creatorUserId: string; + roomId: string; + onPermissionGranted: () => void; + isRoomEncrypted?: boolean; +} + +interface IState { + roomMember: RoomMember; + isWrapped: boolean; + widgetDomain: string; +} @replaceableComponent("views.elements.AppPermission") -export default class AppPermission extends React.Component { - static propTypes = { - url: PropTypes.string.isRequired, - creatorUserId: PropTypes.string.isRequired, - roomId: PropTypes.string.isRequired, - onPermissionGranted: PropTypes.func.isRequired, - isRoomEncrypted: PropTypes.bool, - }; - - static defaultProps = { +export default class AppPermission extends React.Component { + static defaultProps: Partial = { onPermissionGranted: () => {}, }; - constructor(props) { + constructor(props: IProps) { super(props); // The first step is to pick apart the widget so we can render information about it @@ -55,16 +64,18 @@ export default class AppPermission extends React.Component { this.state = { ...urlInfo, roomMember, + isWrapped: null, + widgetDomain: null, }; } - parseWidgetUrl() { + private parseWidgetUrl(): { isWrapped: boolean, widgetDomain: string } { const widgetUrl = url.parse(this.props.url); const params = new URLSearchParams(widgetUrl.search); // HACK: We're relying on the query params when we should be relying on the widget's `data`. // This is a workaround for Scalar. - if (WidgetUtils.isScalarUrl(widgetUrl) && params && params.get('url')) { + if (WidgetUtils.isScalarUrl(this.props.url) && params && params.get('url')) { const unwrappedUrl = url.parse(params.get('url')); return { widgetDomain: unwrappedUrl.host || unwrappedUrl.hostname, @@ -80,10 +91,6 @@ export default class AppPermission extends React.Component { render() { const brand = SdkConfig.get().brand; - const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton"); - const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); - const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); - const TextWithTooltip = sdk.getComponent("views.elements.TextWithTooltip"); const displayName = this.state.roomMember ? this.state.roomMember.name : this.props.creatorUserId; const userId = displayName === this.props.creatorUserId ? null : this.props.creatorUserId; diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx index 3444c2a3d0..1f0b0f25f4 100644 --- a/src/components/views/messages/MAudioBody.tsx +++ b/src/components/views/messages/MAudioBody.tsx @@ -23,6 +23,7 @@ import AudioPlayer from "../audio_messages/AudioPlayer"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import MFileBody from "./MFileBody"; import { IBodyProps } from "./IBodyProps"; +import { PlaybackManager } from "../../../voice/PlaybackManager"; interface IState { error?: Error; @@ -62,7 +63,7 @@ export default class MAudioBody extends React.PureComponent const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024); // We should have a buffer to work with now: let's set it up - const playback = new Playback(buffer, waveform); + const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform); playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent); this.setState({ playback }); diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index b1e42976db..116fd8bd43 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -23,7 +23,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromContent } from "../../../customisations/Media"; import ErrorDialog from "../dialogs/ErrorDialog"; import { TileShape } from "../rooms/EventTile"; -import { IContent } from "matrix-js-sdk/src"; +import { presentableTextForFile } from "../../../utils/FileUtils"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IBodyProps } from "./IBodyProps"; @@ -93,35 +93,6 @@ export function computedStyle(element: HTMLElement) { return cssText; } -/** - * Extracts a human readable label for the file attachment to use as - * link text. - * - * @param {Object} content The "content" key of the matrix event. - * @param {boolean} withSize Whether to include size information. Default true. - * @return {string} the human readable link text for the attachment. - */ -export function presentableTextForFile(content: IContent, withSize = true): string { - let linkText = _t("Attachment"); - if (content.body && content.body.length > 0) { - // The content body should be the name of the file including a - // file extension. - linkText = content.body; - } - - if (content.info && content.info.size && withSize) { - // If we know the size of the file then add it as human readable - // string to the end of the link text so that the user knows how - // big a file they are downloading. - // The content.info also contains a MIME-type but we don't display - // it since it is "ugly", users generally aren't aware what it - // means and the type of the attachment can usually be inferrered - // from the file extension. - linkText += ' (' + filesize(content.info.size) + ')'; - } - return linkText; -} - interface IProps extends IBodyProps { /* whether or not to show the default placeholder for the file. Defaults to true. */ showGenericPlaceholder: boolean; @@ -170,10 +141,10 @@ export default class MFileBody extends React.Component { let placeholder = null; if (this.props.showGenericPlaceholder) { placeholder = ( -
+
- { presentableTextForFile(content, false) } + { presentableTextForFile(content, _t("Attachment"), false) }
); diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 81b6cd634a..44c15d50e7 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -361,8 +361,6 @@ export default class MImageBody extends React.Component { const thumbnail = (
- { /* Calculate aspect ratio, using %padding will size _container correctly */ } -
{ showPlaceholder &&
{ } // Overidden by MStickerBody - protected getFileBody(): JSX.Element { + protected getFileBody(): string | JSX.Element { // We only ever need the download bar if we're appearing outside of the timeline if (this.props.tileShape) { return ; diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx index 5f7f0da3ca..8d92920226 100644 --- a/src/components/views/messages/MImageReplyBody.tsx +++ b/src/components/views/messages/MImageReplyBody.tsx @@ -16,9 +16,11 @@ limitations under the License. import React from "react"; import MImageBody from "./MImageBody"; -import { presentableTextForFile } from "./MFileBody"; +import { presentableTextForFile } from "../../../utils/FileUtils"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import SenderProfile from "./SenderProfile"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { _t } from "../../../languageHandler"; const FORCED_IMAGE_HEIGHT = 44; @@ -32,8 +34,9 @@ export default class MImageReplyBody extends MImageBody { } // Don't show "Download this_file.png ..." - public getFileBody(): JSX.Element { - return <>{ presentableTextForFile(this.props.mxEvent.getContent()) }; + public getFileBody(): string { + const sticker = this.props.mxEvent.getType() === EventType.Sticker; + return presentableTextForFile(this.props.mxEvent.getContent(), sticker ? _t("Sticker") : _t("Image"), !sticker); } render() { diff --git a/src/components/views/messages/MStickerBody.js b/src/components/views/messages/MStickerBody.tsx similarity index 88% rename from src/components/views/messages/MStickerBody.js rename to src/components/views/messages/MStickerBody.tsx index 31af66baf5..61be246ed9 100644 --- a/src/components/views/messages/MStickerBody.js +++ b/src/components/views/messages/MStickerBody.tsx @@ -23,16 +23,16 @@ import { BLURHASH_FIELD } from "../../../ContentMessages"; @replaceableComponent("views.messages.MStickerBody") export default class MStickerBody extends MImageBody { // Mostly empty to prevent default behaviour of MImageBody - onClick(ev) { + protected onClick = (ev: React.MouseEvent) => { ev.preventDefault(); if (!this.state.showImage) { this.showImage(); } - } + }; // MStickerBody doesn't need a wrapping ``, but it does need extra padding // which is added by mx_MStickerBody_wrapper - wrapImage(contentUrl, children) { + protected wrapImage(contentUrl: string, children: React.ReactNode): JSX.Element { let onClick = null; if (!this.state.showImage) { onClick = this.onClick; @@ -42,13 +42,13 @@ export default class MStickerBody extends MImageBody { // Placeholder to show in place of the sticker image if // img onLoad hasn't fired yet. - getPlaceholder(width, height) { + protected getPlaceholder(width: number, height: number): JSX.Element { if (this.props.mxEvent.getContent().info[BLURHASH_FIELD]) return super.getPlaceholder(width, height); return ; } // Tooltip to show on mouse over - getTooltip() { + protected getTooltip(): JSX.Element { const content = this.props.mxEvent && this.props.mxEvent.getContent(); if (!content || !content.body || !content.info || !content.info.w) return null; @@ -60,7 +60,7 @@ export default class MStickerBody extends MImageBody { } // Don't show "Download this_file.png ..." - getFileBody() { + protected getFileBody() { return null; } } diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 666fc1cbe0..969caccaee 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -514,7 +514,7 @@ export default class TextualBody extends React.Component { switch (content.msgtype) { case MsgType.Emote: return ( - +
{   { body } { widgets } - +
); case MsgType.Notice: return ( - +
{ body } { widgets } - +
); default: // including "m.text" return ( - +
{ body } { widgets } - +
); } } diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 84521158df..9e7d9ca205 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -68,7 +68,7 @@ interface IState { suggestedRooms: ISuggestedRoom[]; } -const TAG_ORDER: TagID[] = [ +export const TAG_ORDER: TagID[] = [ DefaultTagID.Invite, DefaultTagID.Favourite, DefaultTagID.DM, diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index ae4569fbaf..4d6de10e1f 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -419,7 +419,7 @@ export default class RoomTile extends React.PureComponent { > { private onPaste = (event: ClipboardEvent): boolean => { const { clipboardData } = event; - // Prioritize text on the clipboard over files as Office on macOS puts a bitmap - // in the clipboard as well as the content being copied. - if (clipboardData.files.length && !clipboardData.types.some(t => t === "text/plain")) { - // This actually not so much for 'files' as such (at time of writing - // neither chrome nor firefox let you paste a plain file copied - // from Finder) but more images copied from a different website - // / word processor etc. + // Prioritize text on the clipboard over files if RTF is present as Office on macOS puts a bitmap + // in the clipboard as well as the content being copied. Modern versions of Office seem to not do this anymore. + // We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer + // it puts the filename in as text/plain which we want to ignore. + if (clipboardData.files.length && !clipboardData.types.includes("text/rtf")) { ContentMessages.sharedInstance().sendContentListToRoom( Array.from(clipboardData.files), this.props.room.roomId, this.context, ); diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index a5a2c4c963..f0df64fcb4 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -68,37 +68,49 @@ export default class VoiceRecordComposerTile extends React.PureComponent Math.round(v * 1024)), - }, - "org.matrix.msc3245.voice": {}, // No content, this is a rendering hint - }); + // noinspection ES6MissingAwait - we don't care if it fails, it'll get queued. + MatrixClientPeg.get().sendMessage(this.props.room.roomId, { + "body": "Voice message", + //"msgtype": "org.matrix.msc2516.voice", + "msgtype": MsgType.Audio, + "url": upload.mxc, + "file": upload.encrypted, + "info": { + duration: Math.round(this.state.recorder.durationSeconds * 1000), + mimetype: this.state.recorder.contentType, + size: this.state.recorder.contentLength, + }, + + // MSC1767 + Ideals of MSC2516 as MSC3245 + // https://github.com/matrix-org/matrix-doc/pull/3245 + "org.matrix.msc1767.text": "Voice message", + "org.matrix.msc1767.file": { + url: upload.mxc, + file: upload.encrypted, + name: "Voice message.ogg", + mimetype: this.state.recorder.contentType, + size: this.state.recorder.contentLength, + }, + "org.matrix.msc1767.audio": { + duration: Math.round(this.state.recorder.durationSeconds * 1000), + + // https://github.com/matrix-org/matrix-doc/pull/3246 + waveform: this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)), + }, + "org.matrix.msc3245.voice": {}, // No content, this is a rendering hint + }); + } catch (e) { + console.error("Error sending/uploading voice message:", e); + Modal.createTrackedDialog('Upload failed', '', ErrorDialog, { + title: _t('Upload Failed'), + description: _t("The voice message failed to upload."), + }); + return; // don't dispose the recording so the user can retry, maybe + } await this.disposeRecording(); } diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index bd488f42b6..d1c497b351 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -393,7 +393,7 @@ export default class AppearanceUserSettingsTab extends React.Component{ _t("Message layout") }
-
{ _t("IRC") } -
-
-
+
-
-
+
+
; }; diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 60ebec0752..1c4043f150 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -76,7 +76,11 @@ const SpaceButton: React.FC = ({ let notifBadge; if (notificationState) { notifBadge =
- + SpaceStore.instance.setActiveRoomInSpace(space)} + forceCount={false} + notification={notificationState} + />
; } diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index cf48658169..c4a2a8f9d3 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -401,7 +401,11 @@ export class SpaceItem extends React.PureComponent { let notifBadge; if (notificationState) { notifBadge =
- + SpaceStore.instance.setActiveRoomInSpace(space)} + forceCount={false} + notification={notificationState} + />
; } diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 2a84c1f110..0507a3e252 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -41,6 +41,10 @@ const RoomContext = createContext({ canReply: false, layout: Layout.Group, lowBandwidth: false, + alwaysShowTimestamps: false, + showTwelveHourTimestamps: false, + readMarkerInViewThresholdMs: 3000, + readMarkerOutOfViewThresholdMs: 30000, showHiddenEventsInTimeline: false, showReadReceipts: true, showRedactions: true, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f081ec823b..f48f06a791 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -657,6 +657,7 @@ "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", "Please
contact your service administrator to continue using the service.": "Please contact your service administrator to continue using the service.", "Unable to connect to Homeserver. Retrying...": "Unable to connect to Homeserver. Retrying...", + "Attachment": "Attachment", "%(items)s and %(count)s others|other": "%(items)s and %(count)s others", "%(items)s and %(count)s others|one": "%(items)s and one other", "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", @@ -1640,6 +1641,7 @@ "Show %(count)s more|other": "Show %(count)s more", "Show %(count)s more|one": "Show %(count)s more", "Show less": "Show less", + "Use default": "Use default", "All messages": "All messages", "Mentions & Keywords": "Mentions & Keywords", "Notification options": "Notification options", @@ -1676,6 +1678,7 @@ "Invited by %(sender)s": "Invited by %(sender)s", "Jump to first unread message.": "Jump to first unread message.", "Mark all as read": "Mark all as read", + "The voice message failed to upload.": "The voice message failed to upload.", "Unable to access your microphone": "Unable to access your microphone", "We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.", "No microphone found": "No microphone found", @@ -1878,13 +1881,14 @@ "Retry": "Retry", "Reply": "Reply", "Message Actions": "Message Actions", - "Attachment": "Attachment", "Error decrypting attachment": "Error decrypting attachment", "Decrypt %(text)s": "Decrypt %(text)s", "Download %(text)s": "Download %(text)s", "Invalid file%(extra)s": "Invalid file%(extra)s", "Error decrypting image": "Error decrypting image", "Show image": "Show image", + "Sticker": "Sticker", + "Image": "Image", "Join the conference at the top of this room": "Join the conference at the top of this room", "Join the conference from the room information card on the right": "Join the conference from the room information card on the right", "Video conference ended by %(senderName)s": "Video conference ended by %(senderName)s", @@ -2598,6 +2602,7 @@ "Use email to optionally be discoverable by existing contacts.": "Use email to optionally be discoverable by existing contacts.", "Sign in with SSO": "Sign in with SSO", "Unnamed audio": "Unnamed audio", + "Error downloading audio": "Error downloading audio", "Pause": "Pause", "Play": "Play", "Couldn't load page": "Couldn't load page", diff --git a/src/phonenumber.ts b/src/phonenumber.ts index ea008cf2f0..51d12babed 100644 --- a/src/phonenumber.ts +++ b/src/phonenumber.ts @@ -42,7 +42,13 @@ export const getEmojiFlag = (countryCode: string) => { return String.fromCodePoint(...countryCode.split('').map(l => UNICODE_BASE + l.charCodeAt(0))); }; -export const COUNTRIES = [ +export interface PhoneNumberCountryDefinition { + iso2: string; + name: string; + prefix: string; +} + +export const COUNTRIES: PhoneNumberCountryDefinition[] = [ { "iso2": "GB", "name": _td("United Kingdom"), diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index 1a6b5109ec..65201134bf 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -38,6 +38,7 @@ import { arrayHasDiff } from "../utils/arrays"; import { objectDiff } from "../utils/objects"; import { arrayHasOrderChange } from "../utils/arrays"; import { reorderLexicographically } from "../utils/stringOrderField"; +import { TAG_ORDER } from "../components/views/rooms/RoomList"; type SpaceKey = string | symbol; @@ -130,6 +131,41 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this._suggestedRooms; } + public async setActiveRoomInSpace(space: Room | null) { + if (space && !space.isSpaceRoom()) return; + if (space !== this.activeSpace) await this.setActiveSpace(space); + + if (space) { + const notificationState = this.getNotificationState(space.roomId); + const roomId = notificationState.getFirstRoomWithNotifications(); + defaultDispatcher.dispatch({ + action: "view_room", + room_id: roomId, + context_switch: true, + }); + } else { + const lists = RoomListStore.instance.unfilteredLists; + for (let i = 0; i < TAG_ORDER.length; i++) { + const t = TAG_ORDER[i]; + const listRooms = lists[t]; + const unreadRoom = listRooms.find((r: Room) => { + if (this.showInHomeSpace(r)) { + const state = RoomNotificationStateStore.instance.getRoomState(r); + return state.isUnread; + } + }); + if (unreadRoom) { + defaultDispatcher.dispatch({ + action: "view_room", + room_id: unreadRoom.roomId, + context_switch: true, + }); + break; + } + } + } + } + /** * Sets the active space, updates room list filters, * optionally switches the user's room back to where they were when they last viewed that space. @@ -138,7 +174,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { * should not be done when the space switch is done implicitly due to another event like switching room. */ public async setActiveSpace(space: Room | null, contextSwitch = true) { - if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return; + if (space === this.activeSpace || (space && !space.isSpaceRoom())) return; this._activeSpace = space; this.emit(UPDATE_SELECTED_SPACE, this.activeSpace); diff --git a/src/stores/UIStore.ts b/src/stores/UIStore.ts index 86312f79d7..b89af2f7c0 100644 --- a/src/stores/UIStore.ts +++ b/src/stores/UIStore.ts @@ -15,7 +15,11 @@ limitations under the License. */ import EventEmitter from "events"; -import ResizeObserver from 'resize-observer-polyfill'; +// XXX: resize-observer-polyfill has types that now conflict with typescript's +// own DOM types: https://github.com/que-etc/resize-observer-polyfill/issues/80 +// Using require here rather than import is a horrenous workaround. We should +// be able to remove the polyfill once Safari 14 is released. +const ResizeObserverPolyfill = require('resize-observer-polyfill'); // eslint-disable-line @typescript-eslint/no-var-requires import ResizeObserverEntry from 'resize-observer-polyfill/src/ResizeObserverEntry'; export enum UI_EVENTS { @@ -43,7 +47,7 @@ export default class UIStore extends EventEmitter { // eslint-disable-next-line no-restricted-properties this.windowHeight = window.innerHeight; - this.resizeObserver = new ResizeObserver(this.resizeObserverCallback); + this.resizeObserver = new ResizeObserverPolyfill(this.resizeObserverCallback); this.resizeObserver.observe(document.body); } diff --git a/src/stores/notifications/SpaceNotificationState.ts b/src/stores/notifications/SpaceNotificationState.ts index 61a9701a07..4c0a582f3f 100644 --- a/src/stores/notifications/SpaceNotificationState.ts +++ b/src/stores/notifications/SpaceNotificationState.ts @@ -53,6 +53,10 @@ export class SpaceNotificationState extends NotificationState { this.calculateTotalState(); } + public getFirstRoomWithNotifications() { + return this.rooms.find((room) => room.getUnreadNotificationCount() > 0).roomId; + } + public destroy() { super.destroy(); for (const state of Object.values(this.states)) { diff --git a/src/utils/FileUtils.ts b/src/utils/FileUtils.ts new file mode 100644 index 0000000000..355fa2135c --- /dev/null +++ b/src/utils/FileUtils.ts @@ -0,0 +1,54 @@ +/* +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner + +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 filesize from 'filesize'; +import { IMediaEventContent } from '../customisations/models/IMediaEventContent'; +import { _t } from '../languageHandler'; + +/** + * Extracts a human readable label for the file attachment to use as + * link text. + * + * @param {IMediaEventContent} content The "content" key of the matrix event. + * @param {string} fallbackText The fallback text + * @param {boolean} withSize Whether to include size information. Default true. + * @return {string} the human readable link text for the attachment. + */ +export function presentableTextForFile( + content: IMediaEventContent, + fallbackText = _t("Attachment"), + withSize = true, +): string { + let text = fallbackText; + if (content.body && content.body.length > 0) { + // The content body should be the name of the file including a + // file extension. + text = content.body; + } + + if (content.info && content.info.size && withSize) { + // If we know the size of the file then add it as human readable + // string to the end of the link text so that the user knows how + // big a file they are downloading. + // The content.info also contains a MIME-type but we don't display + // it since it is "ugly", users generally aren't aware what it + // means and the type of the attachment can usually be inferrered + // from the file extension. + text += ' (' + filesize(content.info.size) + ')'; + } + return text; +} diff --git a/src/utils/PasswordScorer.ts b/src/utils/PasswordScorer.ts index e3c30c1680..9aae4039bd 100644 --- a/src/utils/PasswordScorer.ts +++ b/src/utils/PasswordScorer.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import zxcvbn from 'zxcvbn'; +import zxcvbn, { ZXCVBNFeedbackWarning } from 'zxcvbn'; import { MatrixClientPeg } from '../MatrixClientPeg'; import { _t, _td } from '../languageHandler'; @@ -84,7 +84,7 @@ export function scorePassword(password: string) { } // and warning, if any if (zxcvbnResult.feedback.warning) { - zxcvbnResult.feedback.warning = _t(zxcvbnResult.feedback.warning); + zxcvbnResult.feedback.warning = _t(zxcvbnResult.feedback.warning) as ZXCVBNFeedbackWarning; } return zxcvbnResult; diff --git a/src/voice/ManagedPlayback.ts b/src/voice/ManagedPlayback.ts new file mode 100644 index 0000000000..bff6ce7088 --- /dev/null +++ b/src/voice/ManagedPlayback.ts @@ -0,0 +1,37 @@ +/* +Copyright 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 { DEFAULT_WAVEFORM, Playback } from "./Playback"; +import { PlaybackManager } from "./PlaybackManager"; + +/** + * A managed playback is a Playback instance that is guided by a PlaybackManager. + */ +export class ManagedPlayback extends Playback { + public constructor(private manager: PlaybackManager, buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) { + super(buf, seedWaveform); + } + + public async play(): Promise { + this.manager.playOnly(this); + return super.play(); + } + + public destroy() { + this.manager.destroyPlaybackInstance(this); + super.destroy(); + } +} diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts index 1a1ee54466..33d346629a 100644 --- a/src/voice/Playback.ts +++ b/src/voice/Playback.ts @@ -32,7 +32,7 @@ export enum PlaybackState { export const PLAYBACK_WAVEFORM_SAMPLES = 39; const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120] -const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); +export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); function makePlaybackWaveform(input: number[]): number[] { // First, convert negative amplitudes to positive so we don't detect zero as "noisy". @@ -59,9 +59,10 @@ export class Playback extends EventEmitter implements IDestroyable { public readonly thumbnailWaveform: number[]; private readonly context: AudioContext; - private source: AudioBufferSourceNode; + private source: AudioBufferSourceNode | MediaElementAudioSourceNode; private state = PlaybackState.Decoding; private audioBuf: AudioBuffer; + private element: HTMLAudioElement; private resampledWaveform: number[]; private waveformObservable = new SimpleObservable(); private readonly clock: PlaybackClock; @@ -129,36 +130,64 @@ export class Playback extends EventEmitter implements IDestroyable { this.removeAllListeners(); this.clock.destroy(); this.waveformObservable.close(); + if (this.element) { + URL.revokeObjectURL(this.element.src); + this.element.remove(); + } } public async prepare() { - // Safari compat: promise API not supported on this function - this.audioBuf = await new Promise((resolve, reject) => { - this.context.decodeAudioData(this.buf, b => resolve(b), async e => { - // This error handler is largely for Safari as well, which doesn't support Opus/Ogg - // very well. - console.error("Error decoding recording: ", e); - console.warn("Trying to re-encode to WAV instead..."); + // The point where we use an audio element is fairly arbitrary, though we don't want + // it to be too low. As of writing, voice messages want to show a waveform but audio + // messages do not. Using an audio element means we can't show a waveform preview, so + // we try to target the difference between a voice message file and large audio file. + // Overall, the point of this is to avoid memory-related issues due to storing a massive + // audio buffer in memory, as that can balloon to far greater than the input buffer's + // byte length. + if (this.buf.byteLength > 5 * 1024 * 1024) { // 5mb + console.log("Audio file too large: processing through