Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/17686

 Conflicts:
	src/stores/SpaceStore.tsx
This commit is contained in:
Michael Telatynski 2021-07-22 12:44:27 +01:00
commit 18bb4bce35
52 changed files with 2572 additions and 2251 deletions

View file

@ -160,10 +160,10 @@
@import "./views/groups/_GroupPublicityToggle.scss"; @import "./views/groups/_GroupPublicityToggle.scss";
@import "./views/groups/_GroupRoomList.scss"; @import "./views/groups/_GroupRoomList.scss";
@import "./views/groups/_GroupUserSettings.scss"; @import "./views/groups/_GroupUserSettings.scss";
@import "./views/messages/_CallEvent.scss";
@import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_CreateEvent.scss";
@import "./views/messages/_DateSeparator.scss"; @import "./views/messages/_DateSeparator.scss";
@import "./views/messages/_EventTileBubble.scss"; @import "./views/messages/_EventTileBubble.scss";
@import "./views/messages/_CallEvent.scss";
@import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MFileBody.scss";
@import "./views/messages/_MImageBody.scss"; @import "./views/messages/_MImageBody.scss";
@ -173,7 +173,6 @@
@import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MStickerBody.scss";
@import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MTextBody.scss";
@import "./views/messages/_MVideoBody.scss"; @import "./views/messages/_MVideoBody.scss";
@import "./views/messages/_MVoiceMessageBody.scss";
@import "./views/messages/_MediaBody.scss"; @import "./views/messages/_MediaBody.scss";
@import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageActionBar.scss";
@import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MessageTimestamp.scss";
@ -202,8 +201,8 @@
@import "./views/rooms/_E2EIcon.scss"; @import "./views/rooms/_E2EIcon.scss";
@import "./views/rooms/_EditMessageComposer.scss"; @import "./views/rooms/_EditMessageComposer.scss";
@import "./views/rooms/_EntityTile.scss"; @import "./views/rooms/_EntityTile.scss";
@import "./views/rooms/_EventTile.scss";
@import "./views/rooms/_EventBubbleTile.scss"; @import "./views/rooms/_EventBubbleTile.scss";
@import "./views/rooms/_EventTile.scss";
@import "./views/rooms/_GroupLayout.scss"; @import "./views/rooms/_GroupLayout.scss";
@import "./views/rooms/_IRCLayout.scss"; @import "./views/rooms/_IRCLayout.scss";
@import "./views/rooms/_JumpToBottomButton.scss"; @import "./views/rooms/_JumpToBottomButton.scss";

View file

@ -14,9 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_AudioPlayer_container { .mx_MediaBody.mx_AudioPlayer_container {
padding: 16px 12px 12px 12px; padding: 16px 12px 12px 12px;
max-width: 267px; // use max to make the control fit in the files/pinned panels
.mx_AudioPlayer_primaryContainer { .mx_AudioPlayer_primaryContainer {
display: flex; display: flex;

View file

@ -18,10 +18,10 @@ limitations under the License.
// are shared amongst multiple voice message components. // are shared amongst multiple voice message components.
// Container for live recording and playback controls // Container for live recording and playback controls
.mx_VoiceMessagePrimaryContainer { .mx_MediaBody.mx_VoiceMessagePrimaryContainer {
// 7px top and bottom for visual design. 12px left & right, but the waveform (right) // The waveform (right) has a 1px padding on it that we want to account for, otherwise
// has a 1px padding on it that we want to account for. // inherit from mx_MediaBody
padding: 7px 12px 7px 11px; padding-right: 11px;
// Cheat at alignment a bit // Cheat at alignment a bit
display: flex; display: flex;

View file

@ -20,7 +20,8 @@ limitations under the License.
margin-right: 0; margin-right: 0;
margin-bottom: 8px; margin-bottom: 8px;
padding-left: 10px; padding-left: 10px;
border-left: 4px solid $button-bg-color; border-left: 2px solid $button-bg-color;
border-radius: 2px;
.mx_ReplyThread_show { .mx_ReplyThread_show {
cursor: pointer; cursor: pointer;

View file

@ -60,12 +60,6 @@ limitations under the License.
} }
.mx_MFileBody_info { .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 { .mx_MFileBody_info_icon {
background-color: $message-body-panel-icon-bg-color; background-color: $message-body-panel-icon-bg-color;
border-radius: 20px; border-radius: 20px;

View file

@ -20,9 +20,11 @@ limitations under the License.
.mx_MediaBody { .mx_MediaBody {
background-color: $message-body-panel-bg-color; background-color: $message-body-panel-bg-color;
border-radius: 12px; border-radius: 12px;
max-width: 243px; // use max-width instead of width so it fits within right panels
color: $message-body-panel-fg-color; color: $message-body-panel-fg-color;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-24px; line-height: $font-24px;
}
padding: 6px 12px;
}

View file

@ -80,7 +80,7 @@ limitations under the License.
.mx_MessageActionBar { .mx_MessageActionBar {
right: 0; right: 0;
transform: translate3d(50%, 50%, 0); transform: translate3d(90%, 50%, 0);
} }
--backgroundColor: $eventbubble-others-bg; --backgroundColor: $eventbubble-others-bg;
@ -91,7 +91,7 @@ limitations under the License.
float: right; float: right;
> a { > a {
left: auto; left: auto;
right: -48px; right: -68px;
} }
} }
.mx_SenderProfile { .mx_SenderProfile {
@ -126,7 +126,9 @@ limitations under the License.
margin: 0 -12px 0 -9px; margin: 0 -12px 0 -9px;
> a { > a {
position: absolute; position: absolute;
left: -48px; padding: 10px 20px;
top: 0;
left: -68px;
} }
} }
@ -254,7 +256,7 @@ limitations under the License.
} }
.mx_MessageActionBar { .mx_MessageActionBar {
transform: translate3d(50%, 0, 0); transform: translate3d(90%, 0, 0);
} }
} }

View file

@ -212,43 +212,11 @@ $hover-select-border: 4px;
text-decoration: none; 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 */ /* De-zalgoing */
.mx_EventTile_body { .mx_EventTile_body {
overflow-y: hidden; 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_verified .mx_EventTile_line,
&:hover.mx_EventTile_unverified .mx_EventTile_line, &:hover.mx_EventTile_unverified .mx_EventTile_line,
&:hover.mx_EventTile_unknown .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_RoomView_timeline_rr_enabled {
.mx_EventTile:not([data-layout=bubble]) { .mx_EventTile:not([data-layout=bubble]) {
@ -473,6 +471,10 @@ $hover-select-border: 4px;
background-color: $header-panel-bg-color; background-color: $header-panel-bg-color;
} }
pre code > * {
display: inline-block;
}
pre { pre {
// have to use overlay rather than auto otherwise Linux and Windows // have to use overlay rather than auto otherwise Linux and Windows
// Chrome gets very confused about vertical spacing: // Chrome gets very confused about vertical spacing:

View file

@ -19,7 +19,8 @@ limitations under the License.
margin-right: 15px; margin-right: 15px;
margin-bottom: 15px; margin-bottom: 15px;
display: flex; 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; color: $preview-widget-fg-color;
} }

View file

@ -29,8 +29,10 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
// min-height at this level so the mx_BasicMessageComposer_input // min-height at this level so the mx_BasicMessageComposer_input
// still stays vertically centered when less than 50px // still stays vertically centered when less than 55px.
min-height: 50px; // We also set this to ensure the voice message recording widget
// doesn't cause a jump.
min-height: 55px;
.mx_BasicMessageComposer_input { .mx_BasicMessageComposer_input {
padding: 3px 0; padding: 3px 0;

View file

@ -15,8 +15,7 @@ limitations under the License.
*/ */
.mx_AppearanceUserSettingsTab_fontSlider, .mx_AppearanceUserSettingsTab_fontSlider,
.mx_AppearanceUserSettingsTab_fontSlider_preview, .mx_AppearanceUserSettingsTab_fontSlider_preview {
.mx_AppearanceUserSettingsTab_Layout {
@mixin mx_Settings_fullWidthField; @mixin mx_Settings_fullWidthField;
} }
@ -45,6 +44,11 @@ limitations under the License.
border-radius: 10px; border-radius: 10px;
padding: 0 16px 9px 16px; padding: 0 16px 9px 16px;
pointer-events: none; pointer-events: none;
display: flow-root;
.mx_EventTile[data-layout=bubble] {
margin-top: 30px;
}
.mx_EventTile_msgOption { .mx_EventTile_msgOption {
display: none; display: none;
@ -154,13 +158,10 @@ limitations under the License.
.mx_AppearanceUserSettingsTab_Layout_RadioButtons { .mx_AppearanceUserSettingsTab_Layout_RadioButtons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 24px;
color: $primary-fg-color; color: $primary-fg-color;
.mx_AppearanceUserSettingsTab_spacer {
width: 24px;
}
> .mx_AppearanceUserSettingsTab_Layout_RadioButton { > .mx_AppearanceUserSettingsTab_Layout_RadioButton {
flex-grow: 0; flex-grow: 0;
flex-shrink: 1; flex-shrink: 1;
@ -210,6 +211,21 @@ limitations under the License.
.mx_RadioButton_checked { .mx_RadioButton_checked {
background-color: rgba($accent-color, 0.08); 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 { .mx_AppearanceUserSettingsTab_Advanced {

View file

@ -209,8 +209,8 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #394049; // "Dark Tile" $message-body-panel-bg-color: #394049; // "Dark Tile"
$message-body-panel-icon-fg-color: #21262C; // "Separator" $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $tertiary-fg-color; $message-body-panel-icon-bg-color: #21262C; // "System Dark"
$voice-record-stop-border-color: $quaternary-fg-color; $voice-record-stop-border-color: $quaternary-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
@ -295,3 +295,11 @@ $eventbubble-reply-color: #C1C6CD;
.hljs-tag { .hljs-tag {
color: inherit; // Without this they'd be weirdly blue which doesn't match the theme color: inherit; // Without this they'd be weirdly blue which doesn't match the theme
} }
.hljs-addition {
background: #1a4b59;
}
.hljs-deletion {
background: #53232a;
}

View file

@ -207,8 +207,8 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #394049; $message-body-panel-bg-color: #394049;
$message-body-panel-icon-fg-color: $primary-bg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $secondary-fg-color; $message-body-panel-icon-bg-color: #21262C;
// See non-legacy dark for variable information // See non-legacy dark for variable information
$voice-record-stop-border-color: #6F7882; $voice-record-stop-border-color: #6F7882;

View file

@ -331,7 +331,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #E3E8F0; $message-body-panel-bg-color: #E3E8F0;
$message-body-panel-icon-fg-color: $secondary-fg-color; $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 // See non-legacy _light for variable information
$voice-record-stop-symbol-color: #ff4b55; $voice-record-stop-symbol-color: #ff4b55;

View file

@ -327,7 +327,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #E3E8F0; // "Separator" $message-body-panel-bg-color: #E3E8F0; // "Separator"
$message-body-panel-icon-fg-color: $secondary-fg-color; $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 // These two don't change between themes. They are the $warning-color, but we don't
// want custom themes to affect them by accident. // want custom themes to affect them by accident.

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
.mx_MVoiceMessageBody { declare module "*.svg" {
display: inline-block; // makes the playback controls magically line up const path: string;
export default path;
} }

View file

@ -57,7 +57,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; 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)\/(.+?)\/(.+?)(?:[?/]|$)/; 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:) * @return {String} The shortcode (such as :thumbup:)
*/ */
export function unicodeToShortcode(char: string): string { export function unicodeToShortcode(char: string): string {
const shortcodes = getEmojiFromUnicode(char).shortcodes; const shortcodes = getEmojiFromUnicode(char)?.shortcodes;
return shortcodes.length > 0 ? `:${shortcodes[0]}:` : ''; return shortcodes?.length ? `:${shortcodes[0]}:` : '';
} }
export function processHtmlForSending(html: string): string { export function processHtmlForSending(html: string): string {

View file

@ -14,35 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import PropTypes from "prop-types";
const emailRegex = /^\S+@\S+\.\S+$/; const emailRegex = /^\S+@\S+\.\S+$/;
const mxUserIdRegex = /^@\S+:\S+$/; const mxUserIdRegex = /^@\S+:\S+$/;
const mxRoomIdRegex = /^!\S+:\S+$/; const mxRoomIdRegex = /^!\S+:\S+$/;
export const addressTypes = ['mx-user-id', 'mx-room-id', 'email'];
export enum AddressType { export enum AddressType {
Email = "email", Email = "email",
MatrixUserId = "mx-user-id", MatrixUserId = "mx-user-id",
MatrixRoomId = "mx-room-id", MatrixRoomId = "mx-room-id",
} }
export const addressTypes = [AddressType.Email, AddressType.MatrixRoomId, AddressType.MatrixUserId];
// PropType definition for an object describing // PropType definition for an object describing
// an address that can be invited to a room (which // an address that can be invited to a room (which
// could be a third party identifier or a matrix ID) // could be a third party identifier or a matrix ID)
// along with some additional information about the // along with some additional information about the
// address / target. // address / target.
export const UserAddressType = PropTypes.shape({ export interface IUserAddress {
addressType: PropTypes.oneOf(addressTypes).isRequired, addressType: AddressType;
address: PropTypes.string.isRequired, address: string;
displayName: PropTypes.string, displayName?: string;
avatarMxc: PropTypes.string, avatarMxc?: string;
// true if the address is known to be a valid address (eg. is a real // 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 we've seen) or false otherwise (eg. is just an address the
// user has entered) // user has entered)
isKnown: PropTypes.bool, isKnown?: boolean;
}); }
export function getAddressType(inputText: string): AddressType | null { export function getAddressType(inputText: string): AddressType | null {
if (emailRegex.test(inputText)) { if (emailRegex.test(inputText)) {

View file

@ -236,6 +236,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// A map of <callId, CallEventGrouper> // A map of <callId, CallEventGrouper>
private callEventGroupers = new Map<string, CallEventGrouper>(); private callEventGroupers = new Map<string, CallEventGrouper>();
private membersCount = 0;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -256,11 +258,14 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
componentDidMount() { componentDidMount() {
this.calculateRoomMembersCount();
this.props.room?.on("RoomState.members", this.calculateRoomMembersCount);
this.isMounted = true; this.isMounted = true;
} }
componentWillUnmount() { componentWillUnmount() {
this.isMounted = false; this.isMounted = false;
this.props.room?.off("RoomState.members", this.calculateRoomMembersCount);
SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef); 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 => { private onShowTypingNotificationsChange = (): void => {
this.setState({ this.setState({
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
@ -711,7 +720,6 @@ export default class MessagePanel extends React.Component<IProps, IState> {
isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId(); isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId();
const callEventGrouper = this.callEventGroupers.get(mxEv.getContent().call_id); const callEventGrouper = this.callEventGroupers.get(mxEv.getContent().call_id);
// use txnId as key if available so that we don't remount during sending // use txnId as key if available so that we don't remount during sending
ret.push( ret.push(
<TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}> <TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
@ -743,7 +751,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
enableFlair={this.props.enableFlair} enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts} showReadReceipts={this.props.showReadReceipts}
callEventGrouper={callEventGrouper} callEventGrouper={callEventGrouper}
hideSender={this.props.room.getMembers().length <= 2 && this.props.layout === Layout.Bubble} hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble}
/> />
</TileErrorBoundary>, </TileErrorBoundary>,
); );

View file

@ -166,6 +166,10 @@ export interface IState {
canReply: boolean; canReply: boolean;
layout: Layout; layout: Layout;
lowBandwidth: boolean; lowBandwidth: boolean;
alwaysShowTimestamps: boolean;
showTwelveHourTimestamps: boolean;
readMarkerInViewThresholdMs: number;
readMarkerOutOfViewThresholdMs: number;
showHiddenEventsInTimeline: boolean; showHiddenEventsInTimeline: boolean;
showReadReceipts: boolean; showReadReceipts: boolean;
showRedactions: boolean; showRedactions: boolean;
@ -231,6 +235,10 @@ export default class RoomView extends React.Component<IProps, IState> {
canReply: false, canReply: false,
layout: SettingsStore.getValue("layout"), layout: SettingsStore.getValue("layout"),
lowBandwidth: SettingsStore.getValue("lowBandwidth"), lowBandwidth: SettingsStore.getValue("lowBandwidth"),
alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
showTwelveHourTimestamps: SettingsStore.getValue("showTwelveHourTimestamps"),
readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"),
readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"),
showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"), showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"),
showReadReceipts: true, showReadReceipts: true,
showRedactions: true, showRedactions: true,
@ -263,14 +271,26 @@ export default class RoomView extends React.Component<IProps, IState> {
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
this.settingWatchers = [ this.settingWatchers = [
SettingsStore.watchSetting("layout", null, () => SettingsStore.watchSetting("layout", null, (...[,,, value]) =>
this.setState({ layout: SettingsStore.getValue("layout") }), this.setState({ layout: value as Layout }),
), ),
SettingsStore.watchSetting("lowBandwidth", null, () => SettingsStore.watchSetting("lowBandwidth", null, (...[,,, value]) =>
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }), this.setState({ lowBandwidth: value as boolean }),
), ),
SettingsStore.watchSetting("showHiddenEventsInTimeline", null, () => SettingsStore.watchSetting("alwaysShowTimestamps", null, (...[,,, value]) =>
this.setState({ showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline") }), 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 // Add watchers for each of the settings we just looked up
this.settingWatchers = this.settingWatchers.concat([ this.settingWatchers = this.settingWatchers.concat([
SettingsStore.watchSetting("showReadReceipts", null, () => SettingsStore.watchSetting("showReadReceipts", roomId, (...[,,, value]) =>
this.setState({ this.setState({ showReadReceipts: value as boolean }),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
}),
), ),
SettingsStore.watchSetting("showRedactions", null, () => SettingsStore.watchSetting("showRedactions", roomId, (...[,,, value]) =>
this.setState({ this.setState({ showRedactions: value as boolean }),
showRedactions: SettingsStore.getValue("showRedactions", roomId),
}),
), ),
SettingsStore.watchSetting("showJoinLeaves", null, () => SettingsStore.watchSetting("showJoinLeaves", roomId, (...[,,, value]) =>
this.setState({ this.setState({ showJoinLeaves: value as boolean }),
showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId),
}),
), ),
SettingsStore.watchSetting("showAvatarChanges", null, () => SettingsStore.watchSetting("showAvatarChanges", roomId, (...[,,, value]) =>
this.setState({ this.setState({ showAvatarChanges: value as boolean }),
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
}),
), ),
SettingsStore.watchSetting("showDisplaynameChanges", null, () => SettingsStore.watchSetting("showDisplaynameChanges", roomId, (...[,,, value]) =>
this.setState({ this.setState({ showDisplaynameChanges: value as boolean }),
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
}),
), ),
]); ]);

View file

@ -665,8 +665,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
private readMarkerTimeout(readMarkerPosition: number): number { private readMarkerTimeout(readMarkerPosition: number): number {
return readMarkerPosition === 0 ? return readMarkerPosition === 0 ?
this.state.readMarkerInViewThresholdMs : this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs :
this.state.readMarkerOutOfViewThresholdMs; this.context?.readMarkerOutOfViewThresholdMs ?? this.state.readMarkerOutOfViewThresholdMs;
} }
private async updateReadMarkerOnUserActivity(): Promise<void> { private async updateReadMarkerOnUserActivity(): Promise<void> {
@ -1493,8 +1493,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
onUserScroll={this.props.onUserScroll} onUserScroll={this.props.onUserScroll}
onFillRequest={this.onMessageListFillRequest} onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest} onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour} isTwelveHour={this.context?.showTwelveHourTimestamps ?? this.state.isTwelveHour}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.alwaysShowTimestamps} alwaysShowTimestamps={
this.props.alwaysShowTimestamps ??
this.context?.alwaysShowTimestamps ??
this.state.alwaysShowTimestamps
}
className={this.props.className} className={this.props.className}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}

View file

@ -36,6 +36,7 @@ interface IProps {
interface IState { interface IState {
playbackPhase: PlaybackState; playbackPhase: PlaybackState;
error?: boolean;
} }
@replaceableComponent("views.audio_messages.AudioPlayer") @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 // 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. // is done, and it's not meant to take long anyhow.
// noinspection JSIgnoredPromiseFromCall this.props.playback.prepare().catch(e => {
this.props.playback.prepare(); console.error("Error processing audio file:", e);
this.setState({ error: true });
});
} }
private onPlaybackUpdate = (ev: PlaybackState) => { private onPlaybackUpdate = (ev: PlaybackState) => {
@ -91,34 +94,37 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
public render(): ReactNode { public render(): ReactNode {
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
// events for accessibility // events for accessibility
return <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}> return <>
<div className='mx_AudioPlayer_primaryContainer'> <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
<PlayPauseButton <div className='mx_AudioPlayer_primaryContainer'>
playback={this.props.playback} <PlayPauseButton
playbackPhase={this.state.playbackPhase} playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the button playbackPhase={this.state.playbackPhase}
ref={this.playPauseRef} tabIndex={-1} // prevent tabbing into the button
/> ref={this.playPauseRef}
<div className='mx_AudioPlayer_mediaInfo'> />
<span className='mx_AudioPlayer_mediaName'> <div className='mx_AudioPlayer_mediaInfo'>
{ this.props.mediaName || _t("Unnamed audio") } <span className='mx_AudioPlayer_mediaName'>
</span> { this.props.mediaName || _t("Unnamed audio") }
<div className='mx_AudioPlayer_byline'> </span>
<DurationClock playback={this.props.playback} /> <div className='mx_AudioPlayer_byline'>
&nbsp; { /* easiest way to introduce a gap between the components */ } <DurationClock playback={this.props.playback} />
{ this.renderFileSize() } &nbsp; { /* easiest way to introduce a gap between the components */ }
{ this.renderFileSize() }
</div>
</div> </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>
<div className='mx_AudioPlayer_seek'> { this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
<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>;
} }
} }

View file

@ -22,6 +22,7 @@ import PlaybackClock from "./PlaybackClock";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "../rooms/EventTile"; import { TileShape } from "../rooms/EventTile";
import PlaybackWaveform from "./PlaybackWaveform"; import PlaybackWaveform from "./PlaybackWaveform";
import { _t } from "../../../languageHandler";
interface IProps { interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create // Playback instance to render. Cannot change during component lifecycle: create
@ -33,6 +34,7 @@ interface IProps {
interface IState { interface IState {
playbackPhase: PlaybackState; playbackPhase: PlaybackState;
error?: boolean;
} }
@replaceableComponent("views.audio_messages.RecordingPlayback") @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 // 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. // is done, and it's not meant to take long anyhow.
// noinspection JSIgnoredPromiseFromCall this.props.playback.prepare().catch(e => {
this.props.playback.prepare(); console.error("Error processing audio file:", e);
this.setState({ error: true });
});
} }
private get isWaveformable(): boolean { private get isWaveformable(): boolean {
@ -65,10 +69,13 @@ export default class RecordingPlayback extends React.PureComponent<IProps, IStat
public render(): ReactNode { public render(): ReactNode {
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : ''; const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
return <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}> return <>
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} /> <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
<PlaybackClock playback={this.props.playback} /> <PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> } <PlaybackClock playback={this.props.playback} />
</div>; { this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
</div>
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
</>;
} }
} }

View file

@ -18,14 +18,12 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { sleep } from "matrix-js-sdk/src/utils"; import { sleep } from "matrix-js-sdk/src/utils";
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { addressTypes, getAddressType } from '../../../UserAddress'; import { AddressType, addressTypes, getAddressType, IUserAddress } from '../../../UserAddress';
import GroupStore from '../../../stores/GroupStore'; import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email'; import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient'; import IdentityAuthClient from '../../../IdentityAuthClient';
@ -34,6 +32,10 @@ import { abbreviateUrl } from '../../../utils/UrlUtils';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { replaceableComponent } from "../../../utils/replaceableComponent"; 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 TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
@ -44,29 +46,64 @@ const addressTypeName = {
'email': _td("email address"), 'email': _td("email address"),
}; };
@replaceableComponent("views.dialogs.AddressPickerDialog") interface IResult {
export default class AddressPickerDialog extends React.Component { user_id: string; // eslint-disable-line camelcase
static propTypes = { room_id?: string; // eslint-disable-line camelcase
title: PropTypes.string.isRequired, name?: string;
description: PropTypes.node, display_name?: string; // eslint-disable-line camelcase
// Extra node inserted after picker input, dropdown and errors avatar_url?: string;// eslint-disable-line camelcase
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,
};
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: "", value: "",
focus: true, focus: true,
validAddressTypes: addressTypes, validAddressTypes: addressTypes,
@ -74,36 +111,23 @@ export default class AddressPickerDialog extends React.Component {
includeSelf: false, includeSelf: false,
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this._textinput = createRef();
let validAddressTypes = this.props.validAddressTypes; 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 // 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")) { if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes(AddressType.Email)) {
validAddressTypes = validAddressTypes.filter(type => type !== "email"); validAddressTypes = validAddressTypes.filter(type => type !== AddressType.Email);
} }
this.state = { this.state = {
// Whether to show an error message because of an invalid address
invalidAddressError: false, invalidAddressError: false,
// List of UserAddressType objects representing
// the list of addresses we're going to invite
selectedList: [], selectedList: [],
// Whether a search is ongoing
busy: false, busy: false,
// An error message generated during the user directory search
searchError: null, searchError: null,
// Whether the server supports the user_directory API
serverSupportsUserDirectory: true, serverSupportsUserDirectory: true,
// The query being searched for
query: "", query: "",
// List of UserAddressType objects representing the set of
// auto-completion results for the current search query.
suggestedList: [], 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, validAddressTypes,
}; };
} }
@ -111,11 +135,11 @@ export default class AddressPickerDialog extends React.Component {
componentDidMount() { componentDidMount() {
if (this.props.focus) { if (this.props.focus) {
// Set the cursor at the end of the text input // 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; const { placeholder } = this.props;
if (typeof placeholder === "string") { if (typeof placeholder === "string") {
return placeholder; return placeholder;
@ -124,23 +148,23 @@ export default class AddressPickerDialog extends React.Component {
return placeholder(this.state.validAddressTypes); return placeholder(this.state.validAddressTypes);
} }
onButtonClick = () => { private onButtonClick = (): void => {
let selectedList = this.state.selectedList.slice(); let selectedList = this.state.selectedList.slice();
// Check the text input field to see if user has an unconverted address // 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 there is and it's valid add it to the local selectedList
if (this._textinput.current.value !== '') { if (this.textinput.current.value !== '') {
selectedList = this._addAddressesToList([this._textinput.current.value]); selectedList = this.addAddressesToList([this.textinput.current.value]);
if (selectedList === null) return; if (selectedList === null) return;
} }
this.props.onFinished(true, selectedList); this.props.onFinished(true, selectedList);
}; };
onCancel = () => { private onCancel = (): void => {
this.props.onFinished(false); this.props.onFinished(false);
}; };
onKeyDown = e => { private onKeyDown = (e: React.KeyboardEvent): void => {
const textInput = this._textinput.current ? this._textinput.current.value : undefined; const textInput = this.textinput.current ? this.textinput.current.value : undefined;
if (e.key === Key.ESCAPE) { if (e.key === Key.ESCAPE) {
e.stopPropagation(); e.stopPropagation();
@ -149,15 +173,15 @@ export default class AddressPickerDialog extends React.Component {
} else if (e.key === Key.ARROW_UP) { } else if (e.key === Key.ARROW_UP) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (this.addressSelector) this.addressSelector.moveSelectionUp(); if (this.addressSelector.current) this.addressSelector.current.moveSelectionUp();
} else if (e.key === Key.ARROW_DOWN) { } else if (e.key === Key.ARROW_DOWN) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); 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)) { } else if (this.state.suggestedList.length > 0 && [Key.COMMA, Key.ENTER, Key.TAB].includes(e.key)) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); 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) { } else if (textInput.length === 0 && this.state.selectedList.length && e.key === Key.BACKSPACE) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -169,17 +193,17 @@ export default class AddressPickerDialog extends React.Component {
// if there's nothing in the input box, submit the form // if there's nothing in the input box, submit the form
this.onButtonClick(); this.onButtonClick();
} else { } else {
this._addAddressesToList([textInput]); this.addAddressesToList([textInput]);
} }
} else if (textInput && (e.key === Key.COMMA || e.key === Key.TAB)) { } else if (textInput && (e.key === Key.COMMA || e.key === Key.TAB)) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this._addAddressesToList([textInput]); this.addAddressesToList([textInput]);
} }
}; };
onQueryChanged = ev => { private onQueryChanged = (ev: React.ChangeEvent): void => {
const query = ev.target.value; const query = (ev.target as HTMLTextAreaElement).value;
if (this.queryChangedDebouncer) { if (this.queryChangedDebouncer) {
clearTimeout(this.queryChangedDebouncer); clearTimeout(this.queryChangedDebouncer);
} }
@ -188,17 +212,17 @@ export default class AddressPickerDialog extends React.Component {
this.queryChangedDebouncer = setTimeout(() => { this.queryChangedDebouncer = setTimeout(() => {
if (this.props.pickerType === 'user') { if (this.props.pickerType === 'user') {
if (this.props.groupId) { if (this.props.groupId) {
this._doNaiveGroupSearch(query); this.doNaiveGroupSearch(query);
} else if (this.state.serverSupportsUserDirectory) { } else if (this.state.serverSupportsUserDirectory) {
this._doUserDirectorySearch(query); this.doUserDirectorySearch(query);
} else { } else {
this._doLocalSearch(query); this.doLocalSearch(query);
} }
} else if (this.props.pickerType === 'room') { } else if (this.props.pickerType === 'room') {
if (this.props.groupId) { if (this.props.groupId) {
this._doNaiveGroupRoomSearch(query); this.doNaiveGroupRoomSearch(query);
} else { } else {
this._doRoomSearch(query); this.doRoomSearch(query);
} }
} else { } else {
console.error('Unknown pickerType', this.props.pickerType); 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(); const selectedList = this.state.selectedList.slice();
selectedList.splice(index, 1); selectedList.splice(index, 1);
this.setState({ this.setState({
@ -221,25 +245,21 @@ export default class AddressPickerDialog extends React.Component {
suggestedList: [], suggestedList: [],
query: "", query: "",
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this.cancelThreepidLookup) this.cancelThreepidLookup();
}; };
onClick = index => () => { private onSelected = (index: number): void => {
this.onSelected(index);
};
onSelected = index => {
const selectedList = this.state.selectedList.slice(); const selectedList = this.state.selectedList.slice();
selectedList.push(this._getFilteredSuggestions()[index]); selectedList.push(this.getFilteredSuggestions()[index]);
this.setState({ this.setState({
selectedList, selectedList,
suggestedList: [], suggestedList: [],
query: "", query: "",
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this.cancelThreepidLookup) this.cancelThreepidLookup();
}; };
_doNaiveGroupSearch(query) { private doNaiveGroupSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase(); const lowerCaseQuery = query.toLowerCase();
this.setState({ this.setState({
busy: true, busy: true,
@ -260,7 +280,7 @@ export default class AddressPickerDialog extends React.Component {
display_name: u.displayname, display_name: u.displayname,
}); });
}); });
this._processResults(results, query); this.processResults(results, query);
}).catch((err) => { }).catch((err) => {
console.error('Error whilst searching group rooms: ', err); console.error('Error whilst searching group rooms: ', err);
this.setState({ 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 lowerCaseQuery = query.toLowerCase();
const results = []; const results = [];
GroupStore.getGroupRooms(this.props.groupId).forEach((r) => { GroupStore.getGroupRooms(this.props.groupId).forEach((r) => {
@ -289,13 +309,13 @@ export default class AddressPickerDialog extends React.Component {
name: r.name || r.canonical_alias, name: r.name || r.canonical_alias,
}); });
}); });
this._processResults(results, query); this.processResults(results, query);
this.setState({ this.setState({
busy: false, busy: false,
}); });
} }
_doRoomSearch(query) { private doRoomSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase(); const lowerCaseQuery = query.toLowerCase();
const rooms = MatrixClientPeg.get().getRooms(); const rooms = MatrixClientPeg.get().getRooms();
const results = []; const results = [];
@ -346,13 +366,13 @@ export default class AddressPickerDialog extends React.Component {
return a.rank - b.rank; return a.rank - b.rank;
}); });
this._processResults(sortedResults, query); this.processResults(sortedResults, query);
this.setState({ this.setState({
busy: false, busy: false,
}); });
} }
_doUserDirectorySearch(query) { private doUserDirectorySearch(query: string): void {
this.setState({ this.setState({
busy: true, busy: true,
query, query,
@ -366,7 +386,7 @@ export default class AddressPickerDialog extends React.Component {
if (this.state.query !== query) { if (this.state.query !== query) {
return; return;
} }
this._processResults(resp.results, query); this.processResults(resp.results, query);
}).catch((err) => { }).catch((err) => {
console.error('Error whilst searching user directory: ', err); console.error('Error whilst searching user directory: ', err);
this.setState({ this.setState({
@ -377,7 +397,7 @@ export default class AddressPickerDialog extends React.Component {
serverSupportsUserDirectory: false, serverSupportsUserDirectory: false,
}); });
// Do a local search immediately // Do a local search immediately
this._doLocalSearch(query); this.doLocalSearch(query);
} }
}).then(() => { }).then(() => {
this.setState({ this.setState({
@ -386,7 +406,7 @@ export default class AddressPickerDialog extends React.Component {
}); });
} }
_doLocalSearch(query) { private doLocalSearch(query: string): void {
this.setState({ this.setState({
query, query,
searchError: null, searchError: null,
@ -407,10 +427,10 @@ export default class AddressPickerDialog extends React.Component {
avatar_url: user.avatarUrl, avatar_url: user.avatarUrl,
}); });
}); });
this._processResults(results, query); this.processResults(results, query);
} }
_processResults(results, query) { private processResults(results: IResult[], query: string): void {
const suggestedList = []; const suggestedList = [];
results.forEach((result) => { results.forEach((result) => {
if (result.room_id) { if (result.room_id) {
@ -465,27 +485,27 @@ export default class AddressPickerDialog extends React.Component {
address: query, address: query,
isKnown: false, isKnown: false,
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this.cancelThreepidLookup) this.cancelThreepidLookup();
if (addrType === 'email') { if (addrType === 'email') {
this._lookupThreepid(addrType, query); this.lookupThreepid(addrType, query);
} }
} }
this.setState({ this.setState({
suggestedList, suggestedList,
invalidAddressError: false, 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(); const selectedList = this.state.selectedList.slice();
let hasError = false; let hasError = false;
addressTexts.forEach((addressText) => { addressTexts.forEach((addressText) => {
addressText = addressText.trim(); addressText = addressText.trim();
const addrType = getAddressType(addressText); const addrType = getAddressType(addressText);
const addrObj = { const addrObj: IUserAddress = {
addressType: addrType, addressType: addrType,
address: addressText, address: addressText,
isKnown: false, isKnown: false,
@ -504,7 +524,6 @@ export default class AddressPickerDialog extends React.Component {
const room = MatrixClientPeg.get().getRoom(addrObj.address); const room = MatrixClientPeg.get().getRoom(addrObj.address);
if (room) { if (room) {
addrObj.displayName = room.name; addrObj.displayName = room.name;
addrObj.avatarMxc = room.avatarUrl;
addrObj.isKnown = true; addrObj.isKnown = true;
} }
} }
@ -518,17 +537,17 @@ export default class AddressPickerDialog extends React.Component {
query: "", query: "",
invalidAddressError: hasError ? true : this.state.invalidAddressError, invalidAddressError: hasError ? true : this.state.invalidAddressError,
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this.cancelThreepidLookup) this.cancelThreepidLookup();
return hasError ? null : selectedList; return hasError ? null : selectedList;
} }
async _lookupThreepid(medium, address) { private async lookupThreepid(medium: AddressType, address: string): Promise<string> {
let cancelled = false; let cancelled = false;
// Note that we can't safely remove this after we're done // 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 // 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 // leave it: it's replacing the old one each time so it's
// not like they leak. // not like they leak.
this._cancelThreepidLookup = function() { this.cancelThreepidLookup = function() {
cancelled = true; 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 // map addressType => set of addresses to avoid O(n*m) operation
const selectedAddresses = {}; const selectedAddresses = {};
this.state.selectedList.forEach(({ address, addressType }) => { 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 // Prevent the text being pasted into the textarea
e.preventDefault(); e.preventDefault();
const text = e.clipboardData.getData("text"); const text = e.clipboardData.getData("text");
// Process it as a list of addresses to add instead // 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(); e.preventDefault();
// Update the IS in account data. Actually using it may trigger terms. // 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. // Add email as a valid address type.
const { validAddressTypes } = this.state; const { validAddressTypes } = this.state;
validAddressTypes.push('email'); validAddressTypes.push(AddressType.Email);
this.setState({ validAddressTypes }); this.setState({ validAddressTypes });
}; };
onManageSettingsClick = e => { private onManageSettingsClick = (e: React.MouseEvent): void => {
e.preventDefault(); e.preventDefault();
dis.fire(Action.ViewUserSettings); dis.fire(Action.ViewUserSettings);
this.onCancel(); this.onCancel();
}; };
render() { 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; let inputLabel;
if (this.props.description) { if (this.props.description) {
inputLabel = <div className="mx_AddressPickerDialog_label"> inputLabel = <div className="mx_AddressPickerDialog_label">
@ -627,7 +641,6 @@ export default class AddressPickerDialog extends React.Component {
const query = []; const query = [];
// create the invite list // create the invite list
if (this.state.selectedList.length > 0) { if (this.state.selectedList.length > 0) {
const AddressTile = sdk.getComponent("elements.AddressTile");
for (let i = 0; i < this.state.selectedList.length; i++) { for (let i = 0; i < this.state.selectedList.length; i++) {
query.push( query.push(
<AddressTile <AddressTile
@ -644,10 +657,10 @@ export default class AddressPickerDialog extends React.Component {
query.push( query.push(
<textarea <textarea
key={this.state.selectedList.length} key={this.state.selectedList.length}
onPaste={this._onPaste} onPaste={this.onPaste}
rows="1" rows={1}
id="textinput" id="textinput"
ref={this._textinput} ref={this.textinput}
className="mx_AddressPickerDialog_input" className="mx_AddressPickerDialog_input"
onChange={this.onQueryChanged} onChange={this.onQueryChanged}
placeholder={this.getPlaceholder()} placeholder={this.getPlaceholder()}
@ -656,7 +669,7 @@ export default class AddressPickerDialog extends React.Component {
</textarea>, </textarea>,
); );
const filteredSuggestedList = this._getFilteredSuggestions(); const filteredSuggestedList = this.getFilteredSuggestions();
let error; let error;
let addressSelector; let addressSelector;
@ -675,7 +688,7 @@ export default class AddressPickerDialog extends React.Component {
error = <div className="mx_AddressPickerDialog_error">{ _t("No results") }</div>; error = <div className="mx_AddressPickerDialog_error">{ _t("No results") }</div>;
} else { } else {
addressSelector = ( addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref;}} <AddressSelector ref={this.addressSelector}
addressList={filteredSuggestedList} addressList={filteredSuggestedList}
showAddress={this.props.pickerType === 'user'} showAddress={this.props.pickerType === 'user'}
onSelected={this.onSelected} onSelected={this.onSelected}
@ -686,8 +699,8 @@ export default class AddressPickerDialog extends React.Component {
let identityServer; let identityServer;
// If picker cannot currently accept e-mail but should be able to // If picker cannot currently accept e-mail but should be able to
if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes('email') if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes(AddressType.Email)
&& this.props.validAddressTypes.includes('email')) { && this.props.validAddressTypes.includes(AddressType.Email)) {
const defaultIdentityServerUrl = getDefaultIdentityServerUrl(); const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
if (defaultIdentityServerUrl) { if (defaultIdentityServerUrl) {
identityServer = <div className="mx_AddressPickerDialog_identityServer">{ _t( identityServer = <div className="mx_AddressPickerDialog_identityServer">{ _t(

View file

@ -15,56 +15,62 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import AccessibleButton from './AccessibleButton'; import AccessibleButton from './AccessibleButton';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import Analytics from '../../../Analytics'; import Analytics from '../../../Analytics';
import { replaceableComponent } from "../../../utils/replaceableComponent"; 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") @replaceableComponent("views.elements.ActionButton")
export default class ActionButton extends React.Component { export default class ActionButton extends React.Component<IProps, IState> {
static propTypes = { static defaultProps: Partial<IProps> = {
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 = {
size: "25", size: "25",
tooltip: false, tooltip: false,
}; };
state = { constructor(props: IProps) {
showTooltip: false, super(props);
};
_onClick = (ev) => { this.state = {
showTooltip: false,
};
}
private onClick = (ev: React.MouseEvent): void => {
ev.stopPropagation(); ev.stopPropagation();
Analytics.trackEvent('Action Button', 'click', this.props.action); Analytics.trackEvent('Action Button', 'click', this.props.action);
dis.dispatch({ action: this.props.action }); dis.dispatch({ action: this.props.action });
}; };
_onMouseEnter = () => { private onMouseEnter = (): void => {
if (this.props.tooltip) this.setState({ showTooltip: true }); if (this.props.tooltip) this.setState({ showTooltip: true });
if (this.props.mouseOverAction) { if (this.props.mouseOverAction) {
dis.dispatch({ action: this.props.mouseOverAction }); dis.dispatch({ action: this.props.mouseOverAction });
} }
}; };
_onMouseLeave = () => { private onMouseLeave = (): void => {
this.setState({ showTooltip: false }); this.setState({ showTooltip: false });
}; };
render() { render() {
let tooltip; let tooltip;
if (this.state.showTooltip) { if (this.state.showTooltip) {
const Tooltip = sdk.getComponent("elements.Tooltip");
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={this.props.label} />; tooltip = <Tooltip className="mx_RoleButton_tooltip" label={this.props.label} />;
} }
@ -80,9 +86,9 @@ export default class ActionButton extends React.Component {
return ( return (
<AccessibleButton <AccessibleButton
className={classNames.join(" ")} className={classNames.join(" ")}
onClick={this._onClick} onClick={this.onClick}
onMouseEnter={this._onMouseEnter} onMouseEnter={this.onMouseEnter}
onMouseLeave={this._onMouseLeave} onMouseLeave={this.onMouseLeave}
aria-label={this.props.label} aria-label={this.props.label}
> >
{ icon } { icon }

View file

@ -15,30 +15,37 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import classNames from 'classnames'; import classNames from 'classnames';
import { UserAddressType } from '../../../UserAddress';
import { replaceableComponent } from "../../../utils/replaceableComponent"; 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") @replaceableComponent("views.elements.AddressSelector")
export default class AddressSelector extends React.Component { export default class AddressSelector extends React.Component<IProps, IState> {
static propTypes = { private scrollElement = createRef<HTMLDivElement>();
onSelected: PropTypes.func.isRequired, private addressListElement = createRef<HTMLDivElement>();
// List of the addresses to display constructor(props: IProps) {
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) {
super(props); super(props);
this.state = { this.state = {
@ -48,10 +55,10 @@ export default class AddressSelector extends React.Component {
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // 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 // Make sure the selected item isn't outside the list bounds
const selected = this.state.selected; const selected = this.state.selected;
const maxSelected = this._maxSelected(props.addressList); const maxSelected = this.maxSelected(props.addressList);
if (selected > maxSelected) { if (selected > maxSelected) {
this.setState({ selected: maxSelected }); this.setState({ selected: maxSelected });
} }
@ -60,13 +67,13 @@ export default class AddressSelector extends React.Component {
componentDidUpdate() { componentDidUpdate() {
// As the user scrolls with the arrow keys keep the selected item // As the user scrolls with the arrow keys keep the selected item
// at the top of the window. // at the top of the window.
if (this.scrollElement && this.props.addressList.length > 0 && !this.state.hover) { if (this.scrollElement.current && this.props.addressList.length > 0 && !this.state.hover) {
const elementHeight = this.addressListElement.getBoundingClientRect().height; const elementHeight = this.addressListElement.current.getBoundingClientRect().height;
this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight; this.scrollElement.current.scrollTop = (this.state.selected * elementHeight) - elementHeight;
} }
} }
moveSelectionTop = () => { public moveSelectionTop = (): void => {
if (this.state.selected > 0) { if (this.state.selected > 0) {
this.setState({ this.setState({
selected: 0, selected: 0,
@ -75,7 +82,7 @@ export default class AddressSelector extends React.Component {
} }
}; };
moveSelectionUp = () => { public moveSelectionUp = (): void => {
if (this.state.selected > 0) { if (this.state.selected > 0) {
this.setState({ this.setState({
selected: this.state.selected - 1, selected: this.state.selected - 1,
@ -84,8 +91,8 @@ export default class AddressSelector extends React.Component {
} }
}; };
moveSelectionDown = () => { public moveSelectionDown = (): void => {
if (this.state.selected < this._maxSelected(this.props.addressList)) { if (this.state.selected < this.maxSelected(this.props.addressList)) {
this.setState({ this.setState({
selected: this.state.selected + 1, selected: this.state.selected + 1,
hover: false, hover: false,
@ -93,26 +100,26 @@ export default class AddressSelector extends React.Component {
} }
}; };
chooseSelection = () => { public chooseSelection = (): void => {
this.selectAddress(this.state.selected); this.selectAddress(this.state.selected);
}; };
onClick = index => { private onClick = (index: number): void => {
this.selectAddress(index); this.selectAddress(index);
}; };
onMouseEnter = index => { private onMouseEnter = (index: number): void => {
this.setState({ this.setState({
selected: index, selected: index,
hover: true, hover: true,
}); });
}; };
onMouseLeave = () => { private onMouseLeave = (): void => {
this.setState({ hover: false }); this.setState({ hover: false });
}; };
selectAddress = index => { private selectAddress = (index: number): void => {
// Only try to select an address if one exists // Only try to select an address if one exists
if (this.props.addressList.length !== 0) { if (this.props.addressList.length !== 0) {
this.props.onSelected(index); this.props.onSelected(index);
@ -120,9 +127,8 @@ export default class AddressSelector extends React.Component {
} }
}; };
createAddressListTiles() { private createAddressListTiles(): JSX.Element[] {
const AddressTile = sdk.getComponent("elements.AddressTile"); const maxSelected = this.maxSelected(this.props.addressList);
const maxSelected = this._maxSelected(this.props.addressList);
const addressList = []; const addressList = [];
// Only create the address elements if there are address // 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)} onMouseEnter={this.onMouseEnter.bind(this, i)}
onMouseLeave={this.onMouseLeave} onMouseLeave={this.onMouseLeave}
key={this.props.addressList[i].addressType + "/" + this.props.addressList[i].address} key={this.props.addressList[i].addressType + "/" + this.props.addressList[i].address}
ref={(ref) => { this.addressListElement = ref; }} ref={this.addressListElement}
> >
<AddressTile <AddressTile
address={this.props.addressList[i]} address={this.props.addressList[i]}
showAddress={this.props.showAddress} showAddress={this.props.showAddress}
justified={true} justified={true}
networkName="vector"
networkUrl={require("../../../../res/img/search-icon-vector.svg")}
/> />
</div>, </div>,
); );
@ -159,7 +163,7 @@ export default class AddressSelector extends React.Component {
return addressList; return addressList;
} }
_maxSelected(list) { private maxSelected(list: IUserAddress[]): number {
const listSize = list.length === 0 ? 0 : list.length - 1; const listSize = list.length === 0 ? 0 : list.length - 1;
const maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize; const maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize;
return maxSelected; return maxSelected;
@ -172,7 +176,7 @@ export default class AddressSelector extends React.Component {
}); });
return ( return (
<div className={classes} ref={(ref) => {this.scrollElement = ref;}}> <div className={classes} ref={this.scrollElement}>
{ this.props.header } { this.props.header }
{ this.createAddressListTiles() } { this.createAddressListTiles() }
</div> </div>

View file

@ -16,24 +16,25 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import * as sdk from "../../../index";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { UserAddressType } from '../../../UserAddress';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; 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") @replaceableComponent("views.elements.AddressTile")
export default class AddressTile extends React.Component { export default class AddressTile extends React.Component<IProps> {
static propTypes = { static defaultProps: Partial<IProps> = {
address: UserAddressType.isRequired,
canDismiss: PropTypes.bool,
onDismissed: PropTypes.func,
justified: PropTypes.bool,
};
static defaultProps = {
canDismiss: false, canDismiss: false,
onDismissed: function() {}, // NOP onDismissed: function() {}, // NOP
justified: false, justified: false,
@ -49,11 +50,9 @@ export default class AddressTile extends React.Component {
if (isMatrixAddress && address.avatarMxc) { if (isMatrixAddress && address.avatarMxc) {
imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25)); imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25));
} else if (address.addressType === 'email') { } 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({ const nameClasses = classNames({
"mx_AddressTile_name": true, "mx_AddressTile_name": true,
"mx_AddressTile_justified": this.props.justified, "mx_AddressTile_justified": this.props.justified,
@ -70,9 +69,10 @@ export default class AddressTile extends React.Component {
info = ( info = (
<div className="mx_AddressTile_mx"> <div className="mx_AddressTile_mx">
<div className={nameClasses}>{ name }</div> <div className={nameClasses}>{ name }</div>
{ this.props.showAddress ? {
<div className={idClasses}>{ address.address }</div> : this.props.showAddress
<div /> ? <div className={idClasses}>{ address.address }</div>
: <div />
} }
</div> </div>
); );

View file

@ -17,30 +17,39 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import url from 'url'; import url from 'url';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import WidgetUtils from "../../../utils/WidgetUtils"; import WidgetUtils from "../../../utils/WidgetUtils";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { replaceableComponent } from "../../../utils/replaceableComponent"; 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") @replaceableComponent("views.elements.AppPermission")
export default class AppPermission extends React.Component { export default class AppPermission extends React.Component<IProps, IState> {
static propTypes = { static defaultProps: Partial<IProps> = {
url: PropTypes.string.isRequired,
creatorUserId: PropTypes.string.isRequired,
roomId: PropTypes.string.isRequired,
onPermissionGranted: PropTypes.func.isRequired,
isRoomEncrypted: PropTypes.bool,
};
static defaultProps = {
onPermissionGranted: () => {}, onPermissionGranted: () => {},
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
// The first step is to pick apart the widget so we can render information about it // 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 = { this.state = {
...urlInfo, ...urlInfo,
roomMember, roomMember,
isWrapped: null,
widgetDomain: null,
}; };
} }
parseWidgetUrl() { private parseWidgetUrl(): { isWrapped: boolean, widgetDomain: string } {
const widgetUrl = url.parse(this.props.url); const widgetUrl = url.parse(this.props.url);
const params = new URLSearchParams(widgetUrl.search); const params = new URLSearchParams(widgetUrl.search);
// HACK: We're relying on the query params when we should be relying on the widget's `data`. // HACK: We're relying on the query params when we should be relying on the widget's `data`.
// This is a workaround for Scalar. // 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')); const unwrappedUrl = url.parse(params.get('url'));
return { return {
widgetDomain: unwrappedUrl.host || unwrappedUrl.hostname, widgetDomain: unwrappedUrl.host || unwrappedUrl.hostname,
@ -80,10 +91,6 @@ export default class AppPermission extends React.Component {
render() { render() {
const brand = SdkConfig.get().brand; 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 displayName = this.state.roomMember ? this.state.roomMember.name : this.props.creatorUserId;
const userId = displayName === this.props.creatorUserId ? null : this.props.creatorUserId; const userId = displayName === this.props.creatorUserId ? null : this.props.creatorUserId;

View file

@ -23,6 +23,7 @@ import AudioPlayer from "../audio_messages/AudioPlayer";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import MFileBody from "./MFileBody"; import MFileBody from "./MFileBody";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
import { PlaybackManager } from "../../../voice/PlaybackManager";
interface IState { interface IState {
error?: Error; 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); 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 // 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); playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
this.setState({ playback }); this.setState({ playback });

View file

@ -23,7 +23,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import { TileShape } from "../rooms/EventTile"; import { TileShape } from "../rooms/EventTile";
import { IContent } from "matrix-js-sdk/src"; import { presentableTextForFile } from "../../../utils/FileUtils";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
@ -93,35 +93,6 @@ export function computedStyle(element: HTMLElement) {
return cssText; 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 { interface IProps extends IBodyProps {
/* whether or not to show the default placeholder for the file. Defaults to true. */ /* whether or not to show the default placeholder for the file. Defaults to true. */
showGenericPlaceholder: boolean; showGenericPlaceholder: boolean;
@ -170,10 +141,10 @@ export default class MFileBody extends React.Component<IProps, IState> {
let placeholder = null; let placeholder = null;
if (this.props.showGenericPlaceholder) { if (this.props.showGenericPlaceholder) {
placeholder = ( placeholder = (
<div className="mx_MFileBody_info"> <div className="mx_MediaBody mx_MFileBody_info">
<span className="mx_MFileBody_info_icon" /> <span className="mx_MFileBody_info_icon" />
<span className="mx_MFileBody_info_filename"> <span className="mx_MFileBody_info_filename">
{ presentableTextForFile(content, false) } { presentableTextForFile(content, _t("Attachment"), false) }
</span> </span>
</div> </div>
); );

View file

@ -404,7 +404,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
} }
// Overidden by MStickerBody // 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 // We only ever need the download bar if we're appearing outside of the timeline
if (this.props.tileShape) { if (this.props.tileShape) {
return <MFileBody {...this.props} showGenericPlaceholder={false} />; return <MFileBody {...this.props} showGenericPlaceholder={false} />;

View file

@ -16,9 +16,11 @@ limitations under the License.
import React from "react"; import React from "react";
import MImageBody from "./MImageBody"; import MImageBody from "./MImageBody";
import { presentableTextForFile } from "./MFileBody"; import { presentableTextForFile } from "../../../utils/FileUtils";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import SenderProfile from "./SenderProfile"; import SenderProfile from "./SenderProfile";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from "../../../languageHandler";
const FORCED_IMAGE_HEIGHT = 44; const FORCED_IMAGE_HEIGHT = 44;
@ -32,8 +34,9 @@ export default class MImageReplyBody extends MImageBody {
} }
// Don't show "Download this_file.png ..." // Don't show "Download this_file.png ..."
public getFileBody(): JSX.Element { public getFileBody(): string {
return <>{ presentableTextForFile(this.props.mxEvent.getContent()) }</>; const sticker = this.props.mxEvent.getType() === EventType.Sticker;
return presentableTextForFile(this.props.mxEvent.getContent(), sticker ? _t("Sticker") : _t("Image"), !sticker);
} }
render() { render() {

View file

@ -68,7 +68,7 @@ interface IState {
suggestedRooms: ISuggestedRoom[]; suggestedRooms: ISuggestedRoom[];
} }
const TAG_ORDER: TagID[] = [ export const TAG_ORDER: TagID[] = [
DefaultTagID.Invite, DefaultTagID.Invite,
DefaultTagID.Favourite, DefaultTagID.Favourite,
DefaultTagID.DM, DefaultTagID.DM,

View file

@ -419,7 +419,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
> >
<IconizedContextMenuOptionList first> <IconizedContextMenuOptionList first>
<IconizedContextMenuRadio <IconizedContextMenuRadio
label={_t("Global")} label={_t("Use default")}
active={state === ALL_MESSAGES} active={state === ALL_MESSAGES}
iconClassName="mx_RoomTile_iconBell" iconClassName="mx_RoomTile_iconBell"
onClick={this.onClickAllNotifs} onClick={this.onClickAllNotifs}

View file

@ -514,13 +514,11 @@ export default class SendMessageComposer extends React.Component<IProps> {
private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => { private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => {
const { clipboardData } = event; const { clipboardData } = event;
// Prioritize text on the clipboard over files as Office on macOS puts a bitmap // 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. // in the clipboard as well as the content being copied. Modern versions of Office seem to not do this anymore.
if (clipboardData.files.length && !clipboardData.types.some(t => t === "text/plain")) { // We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
// This actually not so much for 'files' as such (at time of writing // it puts the filename in as text/plain which we want to ignore.
// neither chrome nor firefox let you paste a plain file copied if (clipboardData.files.length && !clipboardData.types.includes("text/rtf")) {
// from Finder) but more images copied from a different website
// / word processor etc.
ContentMessages.sharedInstance().sendContentListToRoom( ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(clipboardData.files), this.props.room.roomId, this.context, Array.from(clipboardData.files), this.props.room.roomId, this.context,
); );

View file

@ -68,37 +68,49 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
} }
await this.state.recorder.stop(); 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 try {
// https://github.com/matrix-org/matrix-doc/pull/3245 const upload = await this.state.recorder.upload(this.props.room.roomId);
"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 // noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
waveform: this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)), MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
}, "body": "Voice message",
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint //"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(); await this.disposeRecording();
} }

View file

@ -393,7 +393,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
<span className="mx_SettingsTab_subheading">{ _t("Message layout") }</span> <span className="mx_SettingsTab_subheading">{ _t("Message layout") }</span>
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons"> <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, mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.IRC,
})}> })}>
<EventTilePreview <EventTilePreview
@ -412,9 +412,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
> >
{ _t("IRC") } { _t("IRC") }
</StyledRadioButton> </StyledRadioButton>
</div> </label>
<div className="mx_AppearanceUserSettingsTab_spacer" /> <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.Group, mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.Group,
})}> })}>
<EventTilePreview <EventTilePreview
@ -433,9 +432,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
> >
{ _t("Modern") } { _t("Modern") }
</StyledRadioButton> </StyledRadioButton>
</div> </label>
<div className="mx_AppearanceUserSettingsTab_spacer" /> <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout === Layout.Bubble, mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout === Layout.Bubble,
})}> })}>
<EventTilePreview <EventTilePreview
@ -454,7 +452,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
> >
{ _t("Message bubbles") } { _t("Message bubbles") }
</StyledRadioButton> </StyledRadioButton>
</div> </label>
</div> </div>
</div>; </div>;
}; };

View file

@ -76,7 +76,11 @@ const SpaceButton: React.FC<IButtonProps> = ({
let notifBadge; let notifBadge;
if (notificationState) { if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer"> notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge forceCount={false} notification={notificationState} /> <NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
forceCount={false}
notification={notificationState}
/>
</div>; </div>;
} }

View file

@ -401,7 +401,11 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
let notifBadge; let notifBadge;
if (notificationState) { if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer"> notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge forceCount={false} notification={notificationState} /> <NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
forceCount={false}
notification={notificationState}
/>
</div>; </div>;
} }

View file

@ -41,6 +41,10 @@ const RoomContext = createContext<IState>({
canReply: false, canReply: false,
layout: Layout.Group, layout: Layout.Group,
lowBandwidth: false, lowBandwidth: false,
alwaysShowTimestamps: false,
showTwelveHourTimestamps: false,
readMarkerInViewThresholdMs: 3000,
readMarkerOutOfViewThresholdMs: 30000,
showHiddenEventsInTimeline: false, showHiddenEventsInTimeline: false,
showReadReceipts: true, showReadReceipts: true,
showRedactions: true, showRedactions: true,

View file

@ -655,6 +655,7 @@
"This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", "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.", "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...", "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|other": "%(items)s and %(count)s others",
"%(items)s and %(count)s others|one": "%(items)s and one other", "%(items)s and %(count)s others|one": "%(items)s and one other",
"%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", "%(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|other": "Show %(count)s more",
"Show %(count)s more|one": "Show %(count)s more", "Show %(count)s more|one": "Show %(count)s more",
"Show less": "Show less", "Show less": "Show less",
"Use default": "Use default",
"All messages": "All messages", "All messages": "All messages",
"Mentions & Keywords": "Mentions & Keywords", "Mentions & Keywords": "Mentions & Keywords",
"Notification options": "Notification options", "Notification options": "Notification options",
@ -1692,6 +1694,7 @@
"Invited by %(sender)s": "Invited by %(sender)s", "Invited by %(sender)s": "Invited by %(sender)s",
"Jump to first unread message.": "Jump to first unread message.", "Jump to first unread message.": "Jump to first unread message.",
"Mark all as read": "Mark all as read", "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", "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.", "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", "No microphone found": "No microphone found",
@ -1894,13 +1897,14 @@
"Retry": "Retry", "Retry": "Retry",
"Reply": "Reply", "Reply": "Reply",
"Message Actions": "Message Actions", "Message Actions": "Message Actions",
"Attachment": "Attachment",
"Error decrypting attachment": "Error decrypting attachment", "Error decrypting attachment": "Error decrypting attachment",
"Decrypt %(text)s": "Decrypt %(text)s", "Decrypt %(text)s": "Decrypt %(text)s",
"Download %(text)s": "Download %(text)s", "Download %(text)s": "Download %(text)s",
"Invalid file%(extra)s": "Invalid file%(extra)s", "Invalid file%(extra)s": "Invalid file%(extra)s",
"Error decrypting image": "Error decrypting image", "Error decrypting image": "Error decrypting image",
"Show image": "Show 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 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", "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", "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.", "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", "Sign in with SSO": "Sign in with SSO",
"Unnamed audio": "Unnamed audio", "Unnamed audio": "Unnamed audio",
"Error downloading audio": "Error downloading audio",
"Pause": "Pause", "Pause": "Pause",
"Play": "Play", "Play": "Play",
"Couldn't load page": "Couldn't load page", "Couldn't load page": "Couldn't load page",

View file

@ -41,6 +41,7 @@ import { arrayHasDiff } from "../utils/arrays";
import { objectDiff } from "../utils/objects"; import { objectDiff } from "../utils/objects";
import { arrayHasOrderChange } from "../utils/arrays"; import { arrayHasOrderChange } from "../utils/arrays";
import { reorderLexicographically } from "../utils/stringOrderField"; import { reorderLexicographically } from "../utils/stringOrderField";
import { TAG_ORDER } from "../components/views/rooms/RoomList";
import { shouldShowSpaceSettings } from "../utils/space"; import { shouldShowSpaceSettings } from "../utils/space";
import ToastStore from "./ToastStore"; import ToastStore from "./ToastStore";
import { _t } from "../languageHandler"; import { _t } from "../languageHandler";
@ -140,6 +141,41 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this._suggestedRooms; 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 { public get restrictedJoinRuleSupport(): IRoomCapability {
return this._restrictedJoinRuleSupport; 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. * 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) { 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._activeSpace = space;
this.emit(UPDATE_SELECTED_SPACE, this.activeSpace); this.emit(UPDATE_SELECTED_SPACE, this.activeSpace);

View file

@ -15,7 +15,11 @@ limitations under the License.
*/ */
import EventEmitter from "events"; 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'; import ResizeObserverEntry from 'resize-observer-polyfill/src/ResizeObserverEntry';
export enum UI_EVENTS { export enum UI_EVENTS {
@ -43,7 +47,7 @@ export default class UIStore extends EventEmitter {
// eslint-disable-next-line no-restricted-properties // eslint-disable-next-line no-restricted-properties
this.windowHeight = window.innerHeight; this.windowHeight = window.innerHeight;
this.resizeObserver = new ResizeObserver(this.resizeObserverCallback); this.resizeObserver = new ResizeObserverPolyfill(this.resizeObserverCallback);
this.resizeObserver.observe(document.body); this.resizeObserver.observe(document.body);
} }

View file

@ -53,6 +53,10 @@ export class SpaceNotificationState extends NotificationState {
this.calculateTotalState(); this.calculateTotalState();
} }
public getFirstRoomWithNotifications() {
return this.rooms.find((room) => room.getUnreadNotificationCount() > 0).roomId;
}
public destroy() { public destroy() {
super.destroy(); super.destroy();
for (const state of Object.values(this.states)) { for (const state of Object.values(this.states)) {

54
src/utils/FileUtils.ts Normal file
View file

@ -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;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import zxcvbn from 'zxcvbn'; import zxcvbn, { ZXCVBNFeedbackWarning } from 'zxcvbn';
import { MatrixClientPeg } from '../MatrixClientPeg'; import { MatrixClientPeg } from '../MatrixClientPeg';
import { _t, _td } from '../languageHandler'; import { _t, _td } from '../languageHandler';
@ -84,7 +84,7 @@ export function scorePassword(password: string) {
} }
// and warning, if any // and warning, if any
if (zxcvbnResult.feedback.warning) { if (zxcvbnResult.feedback.warning) {
zxcvbnResult.feedback.warning = _t(zxcvbnResult.feedback.warning); zxcvbnResult.feedback.warning = _t(zxcvbnResult.feedback.warning) as ZXCVBNFeedbackWarning;
} }
return zxcvbnResult; return zxcvbnResult;

View file

@ -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();
}
}

View file

@ -32,7 +32,7 @@ export enum PlaybackState {
export const PLAYBACK_WAVEFORM_SAMPLES = 39; export const PLAYBACK_WAVEFORM_SAMPLES = 39;
const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120] 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[] { function makePlaybackWaveform(input: number[]): number[] {
// First, convert negative amplitudes to positive so we don't detect zero as "noisy". // 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[]; public readonly thumbnailWaveform: number[];
private readonly context: AudioContext; private readonly context: AudioContext;
private source: AudioBufferSourceNode; private source: AudioBufferSourceNode | MediaElementAudioSourceNode;
private state = PlaybackState.Decoding; private state = PlaybackState.Decoding;
private audioBuf: AudioBuffer; private audioBuf: AudioBuffer;
private element: HTMLAudioElement;
private resampledWaveform: number[]; private resampledWaveform: number[];
private waveformObservable = new SimpleObservable<number[]>(); private waveformObservable = new SimpleObservable<number[]>();
private readonly clock: PlaybackClock; private readonly clock: PlaybackClock;
@ -129,36 +130,64 @@ export class Playback extends EventEmitter implements IDestroyable {
this.removeAllListeners(); this.removeAllListeners();
this.clock.destroy(); this.clock.destroy();
this.waveformObservable.close(); this.waveformObservable.close();
if (this.element) {
URL.revokeObjectURL(this.element.src);
this.element.remove();
}
} }
public async prepare() { public async prepare() {
// Safari compat: promise API not supported on this function // The point where we use an audio element is fairly arbitrary, though we don't want
this.audioBuf = await new Promise((resolve, reject) => { // it to be too low. As of writing, voice messages want to show a waveform but audio
this.context.decodeAudioData(this.buf, b => resolve(b), async e => { // messages do not. Using an audio element means we can't show a waveform preview, so
// This error handler is largely for Safari as well, which doesn't support Opus/Ogg // we try to target the difference between a voice message file and large audio file.
// very well. // Overall, the point of this is to avoid memory-related issues due to storing a massive
console.error("Error decoding recording: ", e); // audio buffer in memory, as that can balloon to far greater than the input buffer's
console.warn("Trying to re-encode to WAV instead..."); // 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 // noinspection ES6MissingAwait - not needed when using callbacks
this.context.decodeAudioData(wav, b => resolve(b), e => { this.context.decodeAudioData(wav, b => resolve(b), e => {
console.error("Still failed to decode recording: ", e); console.error("Still failed to decode recording: ", e);
reject(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 // 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... // exactly trust the user-provided waveform to be accurate...
const waveform = Array.from(this.audioBuf.getChannelData(0)); const waveform = Array.from(this.audioBuf.getChannelData(0));
this.resampledWaveform = makePlaybackWaveform(waveform); this.resampledWaveform = makePlaybackWaveform(waveform);
}
this.waveformObservable.update(this.resampledWaveform); this.waveformObservable.update(this.resampledWaveform);
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore 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.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 () => { private onPlaybackEnd = async () => {
@ -171,7 +200,11 @@ export class Playback extends EventEmitter implements IDestroyable {
if (this.state === PlaybackState.Stopped) { if (this.state === PlaybackState.Stopped) {
this.disconnectSource(); this.disconnectSource();
this.makeNewSourceBuffer(); 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 // 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() { private disconnectSource() {
if (this.element) return; // leave connected, we can (and must) re-use it
this.source?.disconnect(); this.source?.disconnect();
this.source?.removeEventListener("ended", this.onPlaybackEnd); this.source?.removeEventListener("ended", this.onPlaybackEnd);
} }
private makeNewSourceBuffer() { private makeNewSourceBuffer() {
this.source = this.context.createBufferSource(); if (this.element && this.source) return; // leave connected, we can (and must) re-use it
this.source.buffer = this.audioBuf;
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.addEventListener("ended", this.onPlaybackEnd);
this.source.connect(this.context.destination); 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 // 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 // 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. // 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 // 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* // `this.pause()` is as small as possible: we do not want to delay *anything*

View file

@ -103,8 +103,8 @@ export class PlaybackClock implements IDestroyable {
* @param {MatrixEvent} event The event to use for placeholders. * @param {MatrixEvent} event The event to use for placeholders.
*/ */
public populatePlaceholdersFrom(event: MatrixEvent) { public populatePlaceholdersFrom(event: MatrixEvent) {
const durationSeconds = Number(event.getContent()['info']?.['duration']); const durationMs = Number(event.getContent()['info']?.['duration']);
if (Number.isFinite(durationSeconds)) this.placeholderDuration = durationSeconds; if (Number.isFinite(durationMs)) this.placeholderDuration = durationMs / 1000;
} }
/** /**
@ -132,6 +132,10 @@ export class PlaybackClock implements IDestroyable {
public flagStop() { public flagStop() {
this.stopped = true; 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) { public syncTo(contextTime: number, clipTime: number) {

View file

@ -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;
}
}

View file

@ -333,12 +333,17 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
if (this.lastUpload) return this.lastUpload; if (this.lastUpload) return this.lastUpload;
this.emit(RecordingState.Uploading); try {
const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], { this.emit(RecordingState.Uploading);
type: this.contentType, 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); this.lastUpload = { mxc, encrypted };
this.emit(RecordingState.Uploaded);
} catch (e) {
this.emit(RecordingState.Ended);
throw e;
}
return this.lastUpload; return this.lastUpload;
} }
} }

3514
yarn.lock

File diff suppressed because it is too large Load diff