mirror of https://github.com/vector-im/riot-web
				
				
				
			Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/17686
Conflicts: src/stores/SpaceStore.tsxpull/21833/head
						commit
						18bb4bce35
					
				|  | @ -160,10 +160,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"; | ||||
|  | @ -173,7 +173,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"; | ||||
|  | @ -202,8 +201,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"; | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -20,7 +20,8 @@ limitations under the License. | |||
|     margin-right: 0; | ||||
|     margin-bottom: 8px; | ||||
|     padding-left: 10px; | ||||
|     border-left: 4px solid $button-bg-color; | ||||
|     border-left: 2px solid $button-bg-color; | ||||
|     border-radius: 2px; | ||||
| 
 | ||||
|     .mx_ReplyThread_show { | ||||
|         cursor: pointer; | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -212,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 { | ||||
|  | @ -311,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]) { | ||||
|  | @ -473,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: | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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. | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| /* | ||||
| Copyright 2021 The Matrix.org Foundation C.I.C. | ||||
| Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com> | ||||
| 
 | ||||
| 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; | ||||
| } | ||||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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)) { | ||||
|  |  | |||
|  | @ -236,6 +236,8 @@ export default class MessagePanel extends React.Component<IProps, IState> { | |||
|     // A map of <callId, CallEventGrouper>
 | ||||
|     private callEventGroupers = new Map<string, CallEventGrouper>(); | ||||
| 
 | ||||
|     private membersCount = 0; | ||||
| 
 | ||||
|     constructor(props, context) { | ||||
|         super(props, context); | ||||
| 
 | ||||
|  | @ -256,11 +258,14 @@ export default class MessagePanel extends React.Component<IProps, IState> { | |||
|     } | ||||
| 
 | ||||
|     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<IProps, IState> { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     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<IProps, IState> { | |||
|         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( | ||||
|             <TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}> | ||||
|  | @ -743,7 +751,7 @@ export default class MessagePanel extends React.Component<IProps, IState> { | |||
|                     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} | ||||
|                 /> | ||||
|             </TileErrorBoundary>, | ||||
|         ); | ||||
|  |  | |||
|  | @ -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<IProps, IState> { | |||
|             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<IProps, IState> { | |||
|         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<IProps, IState> { | |||
| 
 | ||||
|         // 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 }), | ||||
|             ), | ||||
|         ]); | ||||
| 
 | ||||
|  |  | |||
|  | @ -665,8 +665,8 @@ class TimelinePanel extends React.Component<IProps, IState> { | |||
| 
 | ||||
|     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<void> { | ||||
|  | @ -1493,8 +1493,12 @@ class TimelinePanel extends React.Component<IProps, IState> { | |||
|                 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} | ||||
|  |  | |||
|  | @ -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<IProps, IState> { | |||
| 
 | ||||
|         // 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<IProps, IState> { | |||
|     public render(): ReactNode { | ||||
|         // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
 | ||||
|         // events for accessibility
 | ||||
|         return <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}> | ||||
|             <div className='mx_AudioPlayer_primaryContainer'> | ||||
|                 <PlayPauseButton | ||||
|                     playback={this.props.playback} | ||||
|                     playbackPhase={this.state.playbackPhase} | ||||
|                     tabIndex={-1} // prevent tabbing into the button
 | ||||
|                     ref={this.playPauseRef} | ||||
|                 /> | ||||
|                 <div className='mx_AudioPlayer_mediaInfo'> | ||||
|                     <span className='mx_AudioPlayer_mediaName'> | ||||
|                         { this.props.mediaName || _t("Unnamed audio") } | ||||
|                     </span> | ||||
|                     <div className='mx_AudioPlayer_byline'> | ||||
|                         <DurationClock playback={this.props.playback} /> | ||||
|                           { /* easiest way to introduce a gap between the components */ } | ||||
|                         { this.renderFileSize() } | ||||
|         return <> | ||||
|             <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}> | ||||
|                 <div className='mx_AudioPlayer_primaryContainer'> | ||||
|                     <PlayPauseButton | ||||
|                         playback={this.props.playback} | ||||
|                         playbackPhase={this.state.playbackPhase} | ||||
|                         tabIndex={-1} // prevent tabbing into the button
 | ||||
|                         ref={this.playPauseRef} | ||||
|                     /> | ||||
|                     <div className='mx_AudioPlayer_mediaInfo'> | ||||
|                         <span className='mx_AudioPlayer_mediaName'> | ||||
|                             { this.props.mediaName || _t("Unnamed audio") } | ||||
|                         </span> | ||||
|                         <div className='mx_AudioPlayer_byline'> | ||||
|                             <DurationClock playback={this.props.playback} /> | ||||
|                               { /* easiest way to introduce a gap between the components */ } | ||||
|                             { this.renderFileSize() } | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div className='mx_AudioPlayer_seek'> | ||||
|                     <SeekBar | ||||
|                         playback={this.props.playback} | ||||
|                         tabIndex={-1} // prevent tabbing into the bar
 | ||||
|                         playbackPhase={this.state.playbackPhase} | ||||
|                         ref={this.seekRef} | ||||
|                     /> | ||||
|                     <PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} /> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div className='mx_AudioPlayer_seek'> | ||||
|                 <SeekBar | ||||
|                     playback={this.props.playback} | ||||
|                     tabIndex={-1} // prevent tabbing into the bar
 | ||||
|                     playbackPhase={this.state.playbackPhase} | ||||
|                     ref={this.seekRef} | ||||
|                 /> | ||||
|                 <PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} /> | ||||
|             </div> | ||||
|         </div>; | ||||
|             { this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> } | ||||
|         </>; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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<IProps, IStat | |||
| 
 | ||||
|         // 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 get isWaveformable(): boolean { | ||||
|  | @ -65,10 +69,13 @@ export default class RecordingPlayback extends React.PureComponent<IProps, IStat | |||
| 
 | ||||
|     public render(): ReactNode { | ||||
|         const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : ''; | ||||
|         return <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}> | ||||
|             <PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} /> | ||||
|             <PlaybackClock playback={this.props.playback} /> | ||||
|             { this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> } | ||||
|         </div>; | ||||
|         return <> | ||||
|             <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}> | ||||
|                 <PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} /> | ||||
|                 <PlaybackClock playback={this.props.playback} /> | ||||
|                 { this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> } | ||||
|             </div> | ||||
|             { this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> } | ||||
|         </>; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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<IProps, IState> { | ||||
|     private textinput = createRef<HTMLTextAreaElement>(); | ||||
|     private addressSelector = createRef<AddressSelector>(); | ||||
|     private queryChangedDebouncer: number; | ||||
|     private cancelThreepidLookup: () => void; | ||||
| 
 | ||||
|     static defaultProps: Partial<IProps> = { | ||||
|         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<string> { | ||||
|         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 = <div className="mx_AddressPickerDialog_label"> | ||||
|  | @ -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( | ||||
|                     <AddressTile | ||||
|  | @ -644,10 +657,10 @@ export default class AddressPickerDialog extends React.Component { | |||
|         query.push( | ||||
|             <textarea | ||||
|                 key={this.state.selectedList.length} | ||||
|                 onPaste={this._onPaste} | ||||
|                 rows="1" | ||||
|                 onPaste={this.onPaste} | ||||
|                 rows={1} | ||||
|                 id="textinput" | ||||
|                 ref={this._textinput} | ||||
|                 ref={this.textinput} | ||||
|                 className="mx_AddressPickerDialog_input" | ||||
|                 onChange={this.onQueryChanged} | ||||
|                 placeholder={this.getPlaceholder()} | ||||
|  | @ -656,7 +669,7 @@ export default class AddressPickerDialog extends React.Component { | |||
|             </textarea>, | ||||
|         ); | ||||
| 
 | ||||
|         const filteredSuggestedList = this._getFilteredSuggestions(); | ||||
|         const filteredSuggestedList = this.getFilteredSuggestions(); | ||||
| 
 | ||||
|         let error; | ||||
|         let addressSelector; | ||||
|  | @ -675,7 +688,7 @@ export default class AddressPickerDialog extends React.Component { | |||
|             error = <div className="mx_AddressPickerDialog_error">{ _t("No results") }</div>; | ||||
|         } else { | ||||
|             addressSelector = ( | ||||
|                 <AddressSelector ref={(ref) => {this.addressSelector = ref;}} | ||||
|                 <AddressSelector ref={this.addressSelector} | ||||
|                     addressList={filteredSuggestedList} | ||||
|                     showAddress={this.props.pickerType === 'user'} | ||||
|                     onSelected={this.onSelected} | ||||
|  | @ -686,8 +699,8 @@ export default class AddressPickerDialog extends React.Component { | |||
| 
 | ||||
|         let identityServer; | ||||
|         // If picker cannot currently accept e-mail but should be able to
 | ||||
|         if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes('email') | ||||
|             && this.props.validAddressTypes.includes('email')) { | ||||
|         if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes(AddressType.Email) | ||||
|             && this.props.validAddressTypes.includes(AddressType.Email)) { | ||||
|             const defaultIdentityServerUrl = getDefaultIdentityServerUrl(); | ||||
|             if (defaultIdentityServerUrl) { | ||||
|                 identityServer = <div className="mx_AddressPickerDialog_identityServer">{ _t( | ||||
|  | @ -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<IProps, IState> { | ||||
|     static defaultProps: Partial<IProps> = { | ||||
|         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 = <Tooltip className="mx_RoleButton_tooltip" label={this.props.label} />; | ||||
|         } | ||||
| 
 | ||||
|  | @ -80,9 +86,9 @@ export default class ActionButton extends React.Component { | |||
|         return ( | ||||
|             <AccessibleButton | ||||
|                 className={classNames.join(" ")} | ||||
|                 onClick={this._onClick} | ||||
|                 onMouseEnter={this._onMouseEnter} | ||||
|                 onMouseLeave={this._onMouseLeave} | ||||
|                 onClick={this.onClick} | ||||
|                 onMouseEnter={this.onMouseEnter} | ||||
|                 onMouseLeave={this.onMouseLeave} | ||||
|                 aria-label={this.props.label} | ||||
|             > | ||||
|                 { icon } | ||||
|  | @ -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<IProps, IState> { | ||||
|     private scrollElement = createRef<HTMLDivElement>(); | ||||
|     private addressListElement = createRef<HTMLDivElement>(); | ||||
| 
 | ||||
|         // 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} | ||||
|                     > | ||||
|                         <AddressTile | ||||
|                             address={this.props.addressList[i]} | ||||
|                             showAddress={this.props.showAddress} | ||||
|                             justified={true} | ||||
|                             networkName="vector" | ||||
|                             networkUrl={require("../../../../res/img/search-icon-vector.svg")} | ||||
|                         /> | ||||
|                     </div>, | ||||
|                 ); | ||||
|  | @ -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 ( | ||||
|             <div className={classes} ref={(ref) => {this.scrollElement = ref;}}> | ||||
|             <div className={classes} ref={this.scrollElement}> | ||||
|                 { this.props.header } | ||||
|                 { this.createAddressListTiles() } | ||||
|             </div> | ||||
|  | @ -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<IProps> { | ||||
|     static defaultProps: Partial<IProps> = { | ||||
|         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 = ( | ||||
|                 <div className="mx_AddressTile_mx"> | ||||
|                     <div className={nameClasses}>{ name }</div> | ||||
|                     { this.props.showAddress ? | ||||
|                         <div className={idClasses}>{ address.address }</div> : | ||||
|                         <div /> | ||||
|                     { | ||||
|                         this.props.showAddress | ||||
|                             ? <div className={idClasses}>{ address.address }</div> | ||||
|                             : <div /> | ||||
|                     } | ||||
|                 </div> | ||||
|             ); | ||||
|  | @ -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<IProps, IState> { | ||||
|     static defaultProps: Partial<IProps> = { | ||||
|         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; | ||||
|  | @ -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<IBodyProps, IState> | |||
|         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 }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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<IProps, IState> { | |||
|         let placeholder = null; | ||||
|         if (this.props.showGenericPlaceholder) { | ||||
|             placeholder = ( | ||||
|                 <div className="mx_MFileBody_info"> | ||||
|                 <div className="mx_MediaBody mx_MFileBody_info"> | ||||
|                     <span className="mx_MFileBody_info_icon" /> | ||||
|                     <span className="mx_MFileBody_info_filename"> | ||||
|                         { presentableTextForFile(content, false) } | ||||
|                         { presentableTextForFile(content, _t("Attachment"), false) } | ||||
|                     </span> | ||||
|                 </div> | ||||
|             ); | ||||
|  |  | |||
|  | @ -404,7 +404,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> { | |||
|     } | ||||
| 
 | ||||
|     // 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 <MFileBody {...this.props} showGenericPlaceholder={false} />; | ||||
|  |  | |||
|  | @ -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() { | ||||
|  |  | |||
|  | @ -68,7 +68,7 @@ interface IState { | |||
|     suggestedRooms: ISuggestedRoom[]; | ||||
| } | ||||
| 
 | ||||
| const TAG_ORDER: TagID[] = [ | ||||
| export const TAG_ORDER: TagID[] = [ | ||||
|     DefaultTagID.Invite, | ||||
|     DefaultTagID.Favourite, | ||||
|     DefaultTagID.DM, | ||||
|  |  | |||
|  | @ -419,7 +419,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> { | |||
|             > | ||||
|                 <IconizedContextMenuOptionList first> | ||||
|                     <IconizedContextMenuRadio | ||||
|                         label={_t("Global")} | ||||
|                         label={_t("Use default")} | ||||
|                         active={state === ALL_MESSAGES} | ||||
|                         iconClassName="mx_RoomTile_iconBell" | ||||
|                         onClick={this.onClickAllNotifs} | ||||
|  |  | |||
|  | @ -514,13 +514,11 @@ export default class SendMessageComposer extends React.Component<IProps> { | |||
| 
 | ||||
|     private onPaste = (event: ClipboardEvent<HTMLDivElement>): 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, | ||||
|             ); | ||||
|  |  | |||
|  | @ -68,37 +68,49 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps, | |||
|         } | ||||
| 
 | ||||
|         await this.state.recorder.stop(); | ||||
|         const upload = await this.state.recorder.upload(this.props.room.roomId); | ||||
|         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), | ||||
|         try { | ||||
|             const upload = await this.state.recorder.upload(this.props.room.roomId); | ||||
| 
 | ||||
|                 // 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
 | ||||
|         }); | ||||
|             // 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(); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -393,7 +393,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I | |||
|             <span className="mx_SettingsTab_subheading">{ _t("Message layout") }</span> | ||||
| 
 | ||||
|             <div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons"> | ||||
|                 <div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", { | ||||
|                 <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", { | ||||
|                     mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.IRC, | ||||
|                 })}> | ||||
|                     <EventTilePreview | ||||
|  | @ -412,9 +412,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I | |||
|                     > | ||||
|                         { _t("IRC") } | ||||
|                     </StyledRadioButton> | ||||
|                 </div> | ||||
|                 <div className="mx_AppearanceUserSettingsTab_spacer" /> | ||||
|                 <div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", { | ||||
|                 </label> | ||||
|                 <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", { | ||||
|                     mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.Group, | ||||
|                 })}> | ||||
|                     <EventTilePreview | ||||
|  | @ -433,9 +432,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I | |||
|                     > | ||||
|                         { _t("Modern") } | ||||
|                     </StyledRadioButton> | ||||
|                 </div> | ||||
|                 <div className="mx_AppearanceUserSettingsTab_spacer" /> | ||||
|                 <div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", { | ||||
|                 </label> | ||||
|                 <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", { | ||||
|                     mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout === Layout.Bubble, | ||||
|                 })}> | ||||
|                     <EventTilePreview | ||||
|  | @ -454,7 +452,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I | |||
|                     > | ||||
|                         { _t("Message bubbles") } | ||||
|                     </StyledRadioButton> | ||||
|                 </div> | ||||
|                 </label> | ||||
|             </div> | ||||
|         </div>; | ||||
|     }; | ||||
|  |  | |||
|  | @ -76,7 +76,11 @@ const SpaceButton: React.FC<IButtonProps> = ({ | |||
|     let notifBadge; | ||||
|     if (notificationState) { | ||||
|         notifBadge = <div className="mx_SpacePanel_badgeContainer"> | ||||
|             <NotificationBadge forceCount={false} notification={notificationState} /> | ||||
|             <NotificationBadge | ||||
|                 onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)} | ||||
|                 forceCount={false} | ||||
|                 notification={notificationState} | ||||
|             /> | ||||
|         </div>; | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -401,7 +401,11 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> { | |||
|         let notifBadge; | ||||
|         if (notificationState) { | ||||
|             notifBadge = <div className="mx_SpacePanel_badgeContainer"> | ||||
|                 <NotificationBadge forceCount={false} notification={notificationState} /> | ||||
|                 <NotificationBadge | ||||
|                     onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)} | ||||
|                     forceCount={false} | ||||
|                     notification={notificationState} | ||||
|                 /> | ||||
|             </div>; | ||||
|         } | ||||
| 
 | ||||
|  |  | |||
|  | @ -41,6 +41,10 @@ const RoomContext = createContext<IState>({ | |||
|     canReply: false, | ||||
|     layout: Layout.Group, | ||||
|     lowBandwidth: false, | ||||
|     alwaysShowTimestamps: false, | ||||
|     showTwelveHourTimestamps: false, | ||||
|     readMarkerInViewThresholdMs: 3000, | ||||
|     readMarkerOutOfViewThresholdMs: 30000, | ||||
|     showHiddenEventsInTimeline: false, | ||||
|     showReadReceipts: true, | ||||
|     showRedactions: true, | ||||
|  |  | |||
|  | @ -655,6 +655,7 @@ | |||
|     "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", | ||||
|     "Please <a>contact your service administrator</a> to continue using the service.": "Please <a>contact your service administrator</a> 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", | ||||
|  | @ -1656,6 +1657,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", | ||||
|  | @ -1692,6 +1694,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", | ||||
|  | @ -1894,13 +1897,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", | ||||
|  | @ -2631,6 +2635,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", | ||||
|  |  | |||
|  | @ -41,6 +41,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"; | ||||
| import { shouldShowSpaceSettings } from "../utils/space"; | ||||
| import ToastStore from "./ToastStore"; | ||||
| import { _t } from "../languageHandler"; | ||||
|  | @ -140,6 +141,41 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> { | |||
|         return this._suggestedRooms; | ||||
|     } | ||||
| 
 | ||||
|     public async setActiveRoomInSpace(space: Room | null): Promise<void> { | ||||
|         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; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public get restrictedJoinRuleSupport(): IRoomCapability { | ||||
|         return this._restrictedJoinRuleSupport; | ||||
|     } | ||||
|  | @ -152,7 +188,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> { | |||
|      * 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); | ||||
|  |  | |||
|  | @ -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); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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)) { | ||||
|  |  | |||
|  | @ -0,0 +1,54 @@ | |||
| /* | ||||
| Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. | ||||
| Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com> | ||||
| 
 | ||||
| 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; | ||||
| } | ||||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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<void> { | ||||
|         this.manager.playOnly(this); | ||||
|         return super.play(); | ||||
|     } | ||||
| 
 | ||||
|     public destroy() { | ||||
|         this.manager.destroyPlaybackInstance(this); | ||||
|         super.destroy(); | ||||
|     } | ||||
| } | ||||
|  | @ -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<number[]>(); | ||||
|     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 <audio /> element"); | ||||
|             this.element = document.createElement("AUDIO") as HTMLAudioElement; | ||||
|             const prom = new Promise((resolve, reject) => { | ||||
|                 this.element.onloadeddata = () => resolve(null); | ||||
|                 this.element.onerror = (e) => reject(e); | ||||
|             }); | ||||
|             this.element.src = URL.createObjectURL(new Blob([this.buf])); | ||||
|             await prom; // make sure the audio element is ready for us
 | ||||
|         } else { | ||||
|             // 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 => { | ||||
|                     try { | ||||
|                         // 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..."); | ||||
| 
 | ||||
|                 const wav = await decodeOgg(this.buf); | ||||
|                         const wav = await decodeOgg(this.buf); | ||||
| 
 | ||||
|                 // noinspection ES6MissingAwait - not needed when using callbacks
 | ||||
|                 this.context.decodeAudioData(wav, b => resolve(b), e => { | ||||
|                     console.error("Still failed to decode recording: ", e); | ||||
|                     reject(e); | ||||
|                         // noinspection ES6MissingAwait - not needed when using callbacks
 | ||||
|                         this.context.decodeAudioData(wav, b => resolve(b), e => { | ||||
|                             console.error("Still failed to decode recording: ", e); | ||||
|                             reject(e); | ||||
|                         }); | ||||
|                     } catch (e) { | ||||
|                         console.error("Caught decoding error:", e); | ||||
|                         reject(e); | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         // Update the waveform to the real waveform once we have channel data to use. We don't
 | ||||
|         // exactly trust the user-provided waveform to be accurate...
 | ||||
|         const waveform = Array.from(this.audioBuf.getChannelData(0)); | ||||
|         this.resampledWaveform = makePlaybackWaveform(waveform); | ||||
|             // Update the waveform to the real waveform once we have channel data to use. We don't
 | ||||
|             // exactly trust the user-provided waveform to be accurate...
 | ||||
|             const waveform = Array.from(this.audioBuf.getChannelData(0)); | ||||
|             this.resampledWaveform = makePlaybackWaveform(waveform); | ||||
|         } | ||||
| 
 | ||||
|         this.waveformObservable.update(this.resampledWaveform); | ||||
| 
 | ||||
|         this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
 | ||||
|         this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update
 | ||||
|         this.clock.durationSeconds = this.audioBuf.duration; | ||||
|         this.clock.durationSeconds = this.element ? this.element.duration : this.audioBuf.duration; | ||||
|     } | ||||
| 
 | ||||
|     private onPlaybackEnd = async () => { | ||||
|  | @ -171,7 +200,11 @@ export class Playback extends EventEmitter implements IDestroyable { | |||
|         if (this.state === PlaybackState.Stopped) { | ||||
|             this.disconnectSource(); | ||||
|             this.makeNewSourceBuffer(); | ||||
|             this.source.start(); | ||||
|             if (this.element) { | ||||
|                 await this.element.play(); | ||||
|             } else { | ||||
|                 (this.source as AudioBufferSourceNode).start(); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // We use the context suspend/resume functions because it allows us to pause a source
 | ||||
|  | @ -182,13 +215,21 @@ export class Playback extends EventEmitter implements IDestroyable { | |||
|     } | ||||
| 
 | ||||
|     private disconnectSource() { | ||||
|         if (this.element) return; // leave connected, we can (and must) re-use it
 | ||||
|         this.source?.disconnect(); | ||||
|         this.source?.removeEventListener("ended", this.onPlaybackEnd); | ||||
|     } | ||||
| 
 | ||||
|     private makeNewSourceBuffer() { | ||||
|         this.source = this.context.createBufferSource(); | ||||
|         this.source.buffer = this.audioBuf; | ||||
|         if (this.element && this.source) return; // leave connected, we can (and must) re-use it
 | ||||
| 
 | ||||
|         if (this.element) { | ||||
|             this.source = this.context.createMediaElementSource(this.element); | ||||
|         } else { | ||||
|             this.source = this.context.createBufferSource(); | ||||
|             this.source.buffer = this.audioBuf; | ||||
|         } | ||||
| 
 | ||||
|         this.source.addEventListener("ended", this.onPlaybackEnd); | ||||
|         this.source.connect(this.context.destination); | ||||
|     } | ||||
|  | @ -241,7 +282,11 @@ export class Playback extends EventEmitter implements IDestroyable { | |||
|         // when it comes time to the user hitting play. After a couple jumps, the user
 | ||||
|         // will have desynced the clock enough to be about 10-15 seconds off, while this
 | ||||
|         // keeps it as close to perfect as humans can perceive.
 | ||||
|         this.source.start(now, timeSeconds); | ||||
|         if (this.element) { | ||||
|             this.element.currentTime = timeSeconds; | ||||
|         } else { | ||||
|             (this.source as AudioBufferSourceNode).start(now, timeSeconds); | ||||
|         } | ||||
| 
 | ||||
|         // Dev note: it's critical that the code gap between `this.source.start()` and
 | ||||
|         // `this.pause()` is as small as possible: we do not want to delay *anything*
 | ||||
|  |  | |||
|  | @ -103,8 +103,8 @@ export class PlaybackClock implements IDestroyable { | |||
|      * @param {MatrixEvent} event The event to use for placeholders. | ||||
|      */ | ||||
|     public populatePlaceholdersFrom(event: MatrixEvent) { | ||||
|         const durationSeconds = Number(event.getContent()['info']?.['duration']); | ||||
|         if (Number.isFinite(durationSeconds)) this.placeholderDuration = durationSeconds; | ||||
|         const durationMs = Number(event.getContent()['info']?.['duration']); | ||||
|         if (Number.isFinite(durationMs)) this.placeholderDuration = durationMs / 1000; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -132,6 +132,10 @@ export class PlaybackClock implements IDestroyable { | |||
| 
 | ||||
|     public flagStop() { | ||||
|         this.stopped = true; | ||||
| 
 | ||||
|         // Reset the clock time now so that the update going out will trigger components
 | ||||
|         // to check their seek/position information (alongside the clock).
 | ||||
|         this.clipStart = this.context.currentTime; | ||||
|     } | ||||
| 
 | ||||
|     public syncTo(contextTime: number, clipTime: number) { | ||||
|  |  | |||
|  | @ -0,0 +1,54 @@ | |||
| /* | ||||
| 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 { ManagedPlayback } from "./ManagedPlayback"; | ||||
| 
 | ||||
| /** | ||||
|  * Handles management of playback instances to ensure certain functionality, like | ||||
|  * one playback operating at any one time. | ||||
|  */ | ||||
| export class PlaybackManager { | ||||
|     private static internalInstance: PlaybackManager; | ||||
| 
 | ||||
|     private instances: ManagedPlayback[] = []; | ||||
| 
 | ||||
|     public static get instance(): PlaybackManager { | ||||
|         if (!PlaybackManager.internalInstance) { | ||||
|             PlaybackManager.internalInstance = new PlaybackManager(); | ||||
|         } | ||||
|         return PlaybackManager.internalInstance; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Stops all other playback instances. If no playback is provided, all instances | ||||
|      * are stopped. | ||||
|      * @param playback Optional. The playback to leave untouched. | ||||
|      */ | ||||
|     public playOnly(playback?: Playback) { | ||||
|         this.instances.filter(p => p !== playback).forEach(p => p.stop()); | ||||
|     } | ||||
| 
 | ||||
|     public destroyPlaybackInstance(playback: ManagedPlayback) { | ||||
|         this.instances = this.instances.filter(p => p !== playback); | ||||
|     } | ||||
| 
 | ||||
|     public createPlaybackInstance(buf: ArrayBuffer, waveform = DEFAULT_WAVEFORM): Playback { | ||||
|         const instance = new ManagedPlayback(this, buf, waveform); | ||||
|         this.instances.push(instance); | ||||
|         return instance; | ||||
|     } | ||||
| } | ||||
|  | @ -333,12 +333,17 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { | |||
| 
 | ||||
|         if (this.lastUpload) return this.lastUpload; | ||||
| 
 | ||||
|         this.emit(RecordingState.Uploading); | ||||
|         const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], { | ||||
|             type: this.contentType, | ||||
|         })); | ||||
|         this.lastUpload = { mxc, encrypted }; | ||||
|         this.emit(RecordingState.Uploaded); | ||||
|         try { | ||||
|             this.emit(RecordingState.Uploading); | ||||
|             const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], { | ||||
|                 type: this.contentType, | ||||
|             })); | ||||
|             this.lastUpload = { mxc, encrypted }; | ||||
|             this.emit(RecordingState.Uploaded); | ||||
|         } catch (e) { | ||||
|             this.emit(RecordingState.Ended); | ||||
|             throw e; | ||||
|         } | ||||
|         return this.lastUpload; | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 Michael Telatynski
						Michael Telatynski