diff --git a/CHANGELOG.md b/CHANGELOG.md index ca24b8e447..6d46f19fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +Changes in [3.62.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.62.0) (2022-12-06) +===================================================================================================== + +## ✨ Features + * Further improve replies ([\#6396](https://github.com/matrix-org/matrix-react-sdk/pull/6396)). Fixes vector-im/element-web#19074, vector-im/element-web#18194 vector-im/element-web#18027 and vector-im/element-web#19179. + * Enable users to join group calls from multiple devices ([\#9625](https://github.com/matrix-org/matrix-react-sdk/pull/9625)). + * fix(visual): make cursor a pointer for summaries ([\#9419](https://github.com/matrix-org/matrix-react-sdk/pull/9419)). Contributed by @r00ster91. + * Add placeholder for rich text editor ([\#9613](https://github.com/matrix-org/matrix-react-sdk/pull/9613)). + * Consolidate public room search experience ([\#9605](https://github.com/matrix-org/matrix-react-sdk/pull/9605)). Fixes vector-im/element-web#22846. + * New password reset flow ([\#9581](https://github.com/matrix-org/matrix-react-sdk/pull/9581)). Fixes vector-im/element-web#23131. + * Device manager - add tooltip to device details toggle ([\#9594](https://github.com/matrix-org/matrix-react-sdk/pull/9594)). + * sliding sync: add lazy-loading member support ([\#9530](https://github.com/matrix-org/matrix-react-sdk/pull/9530)). + * Limit formatting bar offset to top of composer ([\#9365](https://github.com/matrix-org/matrix-react-sdk/pull/9365)). Fixes vector-im/element-web#12359. Contributed by @owi92. + +## 🐛 Bug Fixes + * Fix issues around up arrow event edit shortcut ([\#9645](https://github.com/matrix-org/matrix-react-sdk/pull/9645)). Fixes vector-im/element-web#18497 and vector-im/element-web#18964. + * Fix search not being cleared when clicking on a result ([\#9635](https://github.com/matrix-org/matrix-react-sdk/pull/9635)). Fixes vector-im/element-web#23845. + * Fix screensharing in 1:1 calls ([\#9612](https://github.com/matrix-org/matrix-react-sdk/pull/9612)). Fixes vector-im/element-web#23808. + * Fix the background color flashing when joining a call ([\#9640](https://github.com/matrix-org/matrix-react-sdk/pull/9640)). + * Fix the size of the 'Private space' icon ([\#9638](https://github.com/matrix-org/matrix-react-sdk/pull/9638)). + * Fix reply editing in rich text editor (https ([\#9615](https://github.com/matrix-org/matrix-react-sdk/pull/9615)). + * Fix thread list jumping back down while scrolling ([\#9606](https://github.com/matrix-org/matrix-react-sdk/pull/9606)). Fixes vector-im/element-web#23727. + * Fix regression with TimelinePanel props updates not taking effect ([\#9608](https://github.com/matrix-org/matrix-react-sdk/pull/9608)). Fixes vector-im/element-web#23794. + * Fix form tooltip positioning ([\#9598](https://github.com/matrix-org/matrix-react-sdk/pull/9598)). Fixes vector-im/element-web#22861. + * Extract Search handling from RoomView into its own Component ([\#9574](https://github.com/matrix-org/matrix-react-sdk/pull/9574)). Fixes vector-im/element-web#498. + * Fix call splitbrains when switching between rooms ([\#9692](https://github.com/matrix-org/matrix-react-sdk/pull/9692)). + * Fix replies to emotes not showing as inline ([\#9707](https://github.com/matrix-org/matrix-react-sdk/pull/9707)). Fixes vector-im/element-web#23903. + Changes in [3.61.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.61.0) (2022-11-22) ===================================================================================================== diff --git a/package.json b/package.json index 4594988e6a..ea3aa1c18f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.61.0", + "version": "3.62.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 7f21752d4a..2630ad1bc7 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -375,4 +375,5 @@ @import "./voice-broadcast/atoms/_PlaybackControlButton.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss"; +@import "./voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss"; @import "./voice-broadcast/molecules/_VoiceBroadcastBody.pcss"; diff --git a/res/css/views/auth/_AuthBody.pcss b/res/css/views/auth/_AuthBody.pcss index 824f6411df..387c019928 100644 --- a/res/css/views/auth/_AuthBody.pcss +++ b/res/css/views/auth/_AuthBody.pcss @@ -137,15 +137,50 @@ limitations under the License. } /* specialisation for password reset views */ -.mx_AuthBody_forgot-password { +.mx_AuthBody.mx_AuthBody_forgot-password { font-size: $font-14px; color: $primary-content; padding: 50px 32px; min-height: 600px; h1 { - margin-bottom: $spacing-20; - margin-top: $spacing-24; + margin: $spacing-24 0; + } + + .mx_AuthBody_button-container { + display: flex; + justify-content: center; + } + + .mx_Login_submit { + font-weight: $font-semi-bold; + margin: 0 0 $spacing-16; + } + + .mx_AuthBody_text { + margin-bottom: $spacing-32; + + p { + margin: 0 0 $spacing-8; + } + } + + .mx_AuthBody_sign-in-instead-button { + font-weight: $font-semi-bold; + padding: $spacing-4; + } + + .mx_AuthBody_fieldRow { + margin-bottom: $spacing-24; + } + + .mx_AccessibleButton.mx_AccessibleButton_hasKind { + background: none; + + &:disabled { + cursor: default; + opacity: .4; + } } } @@ -154,12 +189,6 @@ limitations under the License. color: $secondary-content; display: flex; gap: $spacing-8; - margin-bottom: 10px; - margin-top: $spacing-24; -} - -.mx_AuthBody_did-not-receive--centered { - justify-content: center; } .mx_AuthBody_resend-button { @@ -168,7 +197,7 @@ limitations under the License. color: $accent; display: flex; gap: $spacing-4; - padding: 4px; + padding: $spacing-4; &:hover { background-color: $system; @@ -209,7 +238,7 @@ limitations under the License. text-align: center; .mx_AuthBody_paddedFooter_title { - margin-top: 16px; + margin-top: $spacing-16; font-size: $font-15px; line-height: $font-24px; @@ -220,7 +249,7 @@ limitations under the License. } .mx_AuthBody_paddedFooter_subtitle { - margin-top: 8px; + margin-top: $spacing-8; font-size: $font-10px; line-height: $font-14px; } @@ -236,7 +265,7 @@ limitations under the License. } .mx_SSOButtons + .mx_AuthBody_changeFlow { - margin-top: 24px; + margin-top: $spacing-24; } .mx_AuthBody_spinner { diff --git a/res/css/views/dialogs/_VerifyEMailDialog.pcss b/res/css/views/dialogs/_VerifyEMailDialog.pcss index fa36f0e114..47541dc452 100644 --- a/res/css/views/dialogs/_VerifyEMailDialog.pcss +++ b/res/css/views/dialogs/_VerifyEMailDialog.pcss @@ -20,8 +20,8 @@ limitations under the License. .mx_Dialog { color: $primary-content; - font-size: 14px; - padding: 16px; + font-size: $font-14px; + padding: $spacing-24 $spacing-24 $spacing-16; text-align: center; width: 485px; @@ -34,5 +34,14 @@ limitations under the License. color: $secondary-content; line-height: 20px; } + + .mx_AuthBody_did-not-receive { + justify-content: center; + margin-bottom: $spacing-8; + } + + .mx_Dialog_cancelButton { + right: 10px; + } } } diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss new file mode 100644 index 0000000000..570a30e6f6 --- /dev/null +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss @@ -0,0 +1,22 @@ +/* +Copyright 2022 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. +*/ + +.mx_RoomTile .mx_RoomTile_titleContainer .mx_RoomTile_subtitle.mx_RoomTile_subtitle--voice-broadcast { + align-items: center; + color: $alert; + display: flex; + gap: $spacing-4; +} diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index ce1a0a26f0..f4d3d6ba7c 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -48,6 +48,7 @@ import { } from "./utils/device/clientInformation"; import SettingsStore, { CallbackFn } from "./settings/SettingsStore"; import { UIFeature } from "./settings/UIFeature"; +import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -335,12 +336,15 @@ export default class DeviceListener { logger.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(',')); logger.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(',')); + const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed(); + // Display or hide the batch toast for old unverified sessions // don't show the toast if the current device is unverified if ( oldUnverifiedDeviceIds.size > 0 && isCurrentDeviceTrusted && this.enableBulkUnverifiedSessionsReminder + && !isBulkUnverifiedSessionsReminderSnoozed ) { showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds); } else { diff --git a/src/Modal.tsx b/src/Modal.tsx index ee24b15d54..53e47cc01a 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -347,7 +347,11 @@ export class ModalManager extends TypedEventEmitter { this.staticModal.elem } -
+
); @@ -368,7 +372,11 @@ export class ModalManager extends TypedEventEmitter { modal.elem }
-
+
); diff --git a/src/PasswordReset.ts b/src/PasswordReset.ts index 1f2c541270..7bcb6ac78e 100644 --- a/src/PasswordReset.ts +++ b/src/PasswordReset.ts @@ -19,8 +19,6 @@ import { createClient, IRequestTokenResponse, MatrixClient } from 'matrix-js-sdk import { _t } from './languageHandler'; -const CHECK_EMAIL_VERIFIED_POLL_INTERVAL = 2000; - /** * Allows a user to reset their password on a homeserver. * @@ -108,24 +106,6 @@ export default class PasswordReset { await this.checkEmailLinkClicked(); } - public async retrySetNewPassword(password: string): Promise { - this.password = password; - return new Promise((resolve) => { - this.tryCheckEmailLinkClicked(resolve); - }); - } - - private tryCheckEmailLinkClicked(resolve: Function): void { - this.checkEmailLinkClicked() - .then(() => resolve()) - .catch(() => { - window.setTimeout( - () => this.tryCheckEmailLinkClicked(resolve), - CHECK_EMAIL_VERIFIED_POLL_INTERVAL, - ); - }); - } - /** * Checks if the email link has been clicked by attempting to change the password * for the mxid linked to the email. diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index fe246aabf7..4698b99ae8 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -19,6 +19,7 @@ limitations under the License. import React, { ReactNode } from 'react'; import { logger } from 'matrix-js-sdk/src/logger'; import { createClient } from "matrix-js-sdk/src/matrix"; +import { sleep } from 'matrix-js-sdk/src/utils'; import { _t, _td } from '../../../languageHandler'; import Modal from "../../../Modal"; @@ -43,6 +44,8 @@ import Spinner from '../../views/elements/Spinner'; import { formatSeconds } from '../../../DateUtils'; import AutoDiscoveryUtils from '../../../utils/AutoDiscoveryUtils'; +const emailCheckInterval = 2000; + enum Phase { // Show email input EnterEmail = 1, @@ -60,7 +63,7 @@ enum Phase { interface Props { serverConfig: ValidatedServerConfig; - onLoginClick?: () => void; + onLoginClick: () => void; onComplete: () => void; } @@ -277,22 +280,43 @@ export default class ForgotPassword extends React.Component { { email: this.state.email, errorText: this.state.errorText, + onCloseClick: () => { + modal.close(); + this.setState({ phase: Phase.PasswordInput }); + }, + onReEnterEmailClick: () => { + modal.close(); + this.setState({ phase: Phase.EnterEmail }); + }, onResendClick: this.sendVerificationMail, }, "mx_VerifyEMailDialog", false, false, { - // this modal cannot be dismissed except reset is done or forced onBeforeClose: async (reason?: string) => { - return this.state.phase === Phase.Done || reason === "force"; + if (reason === "backgroundClick") { + // Modal dismissed by clicking the background. + // Go one phase back. + this.setState({ phase: Phase.PasswordInput }); + } + + return true; }, }, ); - await this.reset.retrySetNewPassword(this.state.password); - this.phase = Phase.Done; - modal.close(); + // Don't retry if the phase changed. For example when going back to email input. + while (this.state.phase === Phase.ResettingPassword) { + try { + await this.reset.setNewPassword(this.state.password); + this.setState({ phase: Phase.Done }); + modal.close(); + } catch (e) { + // Email not confirmed, yet. Retry after a while. + await sleep(emailCheckInterval); + } + } } private onSubmitForm = async (ev: React.FormEvent): Promise => { @@ -339,6 +363,7 @@ export default class ForgotPassword extends React.Component { homeserver={this.props.serverConfig.hsName} loading={this.state.phase === Phase.SendingEmail} onInputChanged={this.onInputChanged} + onLoginClick={this.props.onLoginClick!} // set by default props onSubmitForm={this.onSubmitForm} />; } @@ -374,6 +399,7 @@ export default class ForgotPassword extends React.Component { return this.setState({ phase: Phase.EnterEmail })} onResendClick={this.sendVerificationMail} onSubmitForm={this.onSubmitForm} />; diff --git a/src/components/structures/auth/forgot-password/CheckEmail.tsx b/src/components/structures/auth/forgot-password/CheckEmail.tsx index 27fa82f25e..b1faba936e 100644 --- a/src/components/structures/auth/forgot-password/CheckEmail.tsx +++ b/src/components/structures/auth/forgot-password/CheckEmail.tsx @@ -27,6 +27,7 @@ import { ErrorMessage } from "../../ErrorMessage"; interface CheckEmailProps { email: string; errorText: string | ReactNode | null; + onReEnterEmailClick: () => void; onResendClick: () => Promise; onSubmitForm: (ev: React.FormEvent) => void; } @@ -37,6 +38,7 @@ interface CheckEmailProps { export const CheckEmail: React.FC = ({ email, errorText, + onReEnterEmailClick, onSubmitForm, onResendClick, }) => { @@ -50,13 +52,32 @@ export const CheckEmail: React.FC = ({ return <>

{ _t("Check your email to continue") }

-

- { _t( - "Follow the instructions sent to %(email)s", - { email: email }, - { b: t => { t } }, - ) } -

+
+

+ { _t( + "Follow the instructions sent to %(email)s", + { email: email }, + { b: t => { t } }, + ) } +

+
+ { _t("Wrong email address?") } + + { _t("Re-enter email address") } + +
+
+ { errorText && } +
{ _t("Did not receive it?") } = ({ />
- { errorText && } - ; }; diff --git a/src/components/structures/auth/forgot-password/EnterEmail.tsx b/src/components/structures/auth/forgot-password/EnterEmail.tsx index a630291ae2..3201349b3d 100644 --- a/src/components/structures/auth/forgot-password/EnterEmail.tsx +++ b/src/components/structures/auth/forgot-password/EnterEmail.tsx @@ -22,6 +22,7 @@ import EmailField from "../../../views/auth/EmailField"; import { ErrorMessage } from "../../ErrorMessage"; import Spinner from "../../../views/elements/Spinner"; import Field from "../../../views/elements/Field"; +import AccessibleButton from "../../../views/elements/AccessibleButton"; interface EnterEmailProps { email: string; @@ -29,6 +30,7 @@ interface EnterEmailProps { homeserver: string; loading: boolean; onInputChanged: (stateKey: string, ev: React.FormEvent) => void; + onLoginClick: () => void; onSubmitForm: (ev: React.FormEvent) => void; } @@ -41,6 +43,7 @@ export const EnterEmail: React.FC = ({ homeserver, loading, onInputChanged, + onLoginClick, onSubmitForm, }) => { const submitButtonChild = loading @@ -92,6 +95,15 @@ export const EnterEmail: React.FC = ({ > { submitButtonChild } +
+ + { _t("Sign in instead") } + +
; diff --git a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx index d63e4c97d7..41bdb7a051 100644 --- a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx +++ b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx @@ -27,12 +27,16 @@ import { ErrorMessage } from "../../ErrorMessage"; interface Props { email: string; errorText: string | null; + onCloseClick: () => void; + onReEnterEmailClick: () => void; onResendClick: () => Promise; } export const VerifyEmailModal: React.FC = ({ email, errorText, + onCloseClick, + onReEnterEmailClick, onResendClick, }) => { const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500); @@ -57,7 +61,8 @@ export const VerifyEmailModal: React.FC = ({ }, ) }

-
+ +
{ _t("Did not receive it?") } = ({ { errorText && }
+ +
+ { _t("Wrong email address?") } + + { _t("Re-enter email address") } + +
+ + ; }; diff --git a/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx index e9e97475f7..ebefca56d5 100644 --- a/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx @@ -20,6 +20,7 @@ import classNames from "classnames"; import { formatCount } from "../../../../utils/FormattingUtils"; import AccessibleButton from "../../elements/AccessibleButton"; import { NotificationColor } from "../../../../stores/notifications/NotificationColor"; +import { useSettingValue } from "../../../../hooks/useSettings"; interface Props { symbol: string | null; @@ -37,8 +38,12 @@ export function StatelessNotificationBadge({ count, color, ...props }: Props) { + const hideBold = useSettingValue("feature_hidebold"); + // Don't show a badge if we don't need to - if (color === NotificationColor.None) return null; + if (color === NotificationColor.None || (hideBold && color == NotificationColor.Bold)) { + return null; + } const hasUnreadCount = color >= NotificationColor.Grey && (!!count || !!symbol); diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 68f4dfe4de..d19efb7d1f 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -48,20 +48,25 @@ import { RoomTileCallSummary } from "./RoomTileCallSummary"; import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu"; import { CallStore, CallStoreEvent } from "../../../stores/CallStore"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import { useHasRoomLiveVoiceBroadcast, VoiceBroadcastRoomSubtitle } from "../../../voice-broadcast"; -interface IProps { +interface Props { room: Room; showMessagePreview: boolean; isMinimized: boolean; tag: TagID; } +interface ClassProps extends Props { + hasLiveVoiceBroadcast: boolean; +} + type PartialDOMRect = Pick; -interface IState { +interface State { selected: boolean; - notificationsMenuPosition: PartialDOMRect; - generalMenuPosition: PartialDOMRect; + notificationsMenuPosition: PartialDOMRect | null; + generalMenuPosition: PartialDOMRect | null; call: Call | null; messagePreview?: string; } @@ -76,13 +81,13 @@ export const contextMenuBelow = (elementRect: PartialDOMRect) => { return { left, top, chevronFace }; }; -export default class RoomTile extends React.PureComponent { - private dispatcherRef: string; +export class RoomTile extends React.PureComponent { + private dispatcherRef?: string; private roomTileRef = createRef(); private notificationState: NotificationState; private roomProps: RoomEchoChamber; - constructor(props: IProps) { + constructor(props: ClassProps) { super(props); this.state = { @@ -120,7 +125,7 @@ export default class RoomTile extends React.PureComponent { return !this.props.isMinimized && this.props.showMessagePreview; } - public componentDidUpdate(prevProps: Readonly, prevState: Readonly) { + public componentDidUpdate(prevProps: Readonly, prevState: Readonly) { const showMessageChanged = prevProps.showMessagePreview !== this.props.showMessagePreview; const minimizedChanged = prevProps.isMinimized !== this.props.isMinimized; if (showMessageChanged || minimizedChanged) { @@ -169,7 +174,7 @@ export default class RoomTile extends React.PureComponent { this.onRoomPreviewChanged, ); this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate); - defaultDispatcher.unregister(this.dispatcherRef); + if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate); this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate); CallStore.instance.off(CallStoreEvent.Call, this.onCallChanged); @@ -218,12 +223,14 @@ export default class RoomTile extends React.PureComponent { ev.stopPropagation(); const action = getKeyBindingsManager().getAccessibilityAction(ev); + const clearSearch = ([KeyBindingAction.Enter, KeyBindingAction.Space] as Array) + .includes(action); defaultDispatcher.dispatch({ action: Action.ViewRoom, show_room_tile: true, // make sure the room is visible in the list room_id: this.props.room.roomId, - clear_search: [KeyBindingAction.Enter, KeyBindingAction.Space].includes(action), + clear_search: clearSearch, metricsTrigger: "RoomList", metricsViaKeyboard: ev.type !== "click", }); @@ -233,7 +240,7 @@ export default class RoomTile extends React.PureComponent { this.setState({ selected: isActive }); }; - private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => { + private onNotificationsMenuOpenClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -246,7 +253,7 @@ export default class RoomTile extends React.PureComponent { this.setState({ notificationsMenuPosition: null }); }; - private onGeneralMenuOpenClick = (ev: React.MouseEvent) => { + private onGeneralMenuOpenClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -271,7 +278,7 @@ export default class RoomTile extends React.PureComponent { this.setState({ generalMenuPosition: null }); }; - private renderNotificationsMenu(isActive: boolean): React.ReactElement { + private renderNotificationsMenu(isActive: boolean): React.ReactElement | null { if (MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Archived || !this.showContextMenu || this.props.isMinimized ) { @@ -313,7 +320,7 @@ export default class RoomTile extends React.PureComponent { ); } - private renderGeneralMenu(): React.ReactElement { + private renderGeneralMenu(): React.ReactElement | null { if (!this.showContextMenu) return null; // no menu to show return ( @@ -379,6 +386,8 @@ export default class RoomTile extends React.PureComponent {
); + } else if (this.props.hasLiveVoiceBroadcast) { + subtitle = ; } else if (this.showMessagePreview && this.state.messagePreview) { subtitle = (
{ ); } } + +const RoomTileHOC: React.FC = (props: Props) => { + const hasLiveVoiceBroadcast = useHasRoomLiveVoiceBroadcast(props.room); + return ; +}; + +export default RoomTileHOC; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4c8e4d33c3..b76586eabb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -960,6 +960,7 @@ "Show stickers button": "Show stickers button", "Show polls button": "Show polls button", "Insert a trailing colon after user mentions at the start of a message": "Insert a trailing colon after user mentions at the start of a message", + "Hide notification dot (only display counters badges)": "Hide notification dot (only display counters badges)", "Use a more compact 'Modern' layout": "Use a more compact 'Modern' layout", "Show a placeholder for removed messages": "Show a placeholder for removed messages", "Show join/leave messages (invites/removes/bans unaffected)": "Show join/leave messages (invites/removes/bans unaffected)", @@ -3496,6 +3497,8 @@ "Clear personal data": "Clear personal data", "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.", "Follow the instructions sent to %(email)s": "Follow the instructions sent to %(email)s", + "Wrong email address?": "Wrong email address?", + "Re-enter email address": "Re-enter email address", "Did not receive it?": "Did not receive it?", "Verification link email resent!": "Verification link email resent!", "Send email": "Send email", @@ -3503,6 +3506,7 @@ "%(homeserver)s will send you a verification link to let you reset your password.": "%(homeserver)s will send you a verification link to let you reset your password.", "The email address linked to your account must be entered.": "The email address linked to your account must be entered.", "The email address doesn't appear to be valid.": "The email address doesn't appear to be valid.", + "Sign in instead": "Sign in instead", "Verify your email to continue": "Verify your email to continue", "We need to know it’s you before resetting your password.\n Click the link in the email we just sent to %(email)s": "We need to know it’s you before resetting your password.\n Click the link in the email we just sent to %(email)s", "Commands": "Commands", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index c6472868b7..110a520f84 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -556,11 +556,18 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_ROOM_OR_ACCOUNT, default: false, }, + "feature_hidebold": { + isFeature: true, + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, + displayName: _td("Hide notification dot (only display counters badges)"), + labsGroup: LabGroup.Rooms, + default: false, + }, "useCompactLayout": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, displayName: _td("Use a more compact 'Modern' layout"), default: false, - controller: new IncompatibleController("layout", false, v => v !== Layout.Group), + controller: new IncompatibleController("layout", false, (v: Layout) => v !== Layout.Group), }, "showRedactions": { supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM, diff --git a/src/stores/notifications/ListNotificationState.ts b/src/stores/notifications/ListNotificationState.ts index 8ff1824bd6..37235b0dd6 100644 --- a/src/stores/notifications/ListNotificationState.ts +++ b/src/stores/notifications/ListNotificationState.ts @@ -31,7 +31,7 @@ export class ListNotificationState extends NotificationState { super(); } - public get symbol(): string { + public get symbol(): string | null { return this._color === NotificationColor.Unsent ? "!" : null; } diff --git a/src/stores/notifications/NotificationState.ts b/src/stores/notifications/NotificationState.ts index 60f50fad8c..c963d9c1a0 100644 --- a/src/stores/notifications/NotificationState.ts +++ b/src/stores/notifications/NotificationState.ts @@ -18,6 +18,7 @@ import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter" import { NotificationColor } from "./NotificationColor"; import { IDestroyable } from "../../utils/IDestroyable"; +import SettingsStore from "../../settings/SettingsStore"; export interface INotificationStateSnapshotParams { symbol: string | null; @@ -37,11 +38,22 @@ export abstract class NotificationState extends TypedEventEmitter implements INotificationStateSnapshotParams, IDestroyable { // - protected _symbol: string | null; - protected _count: number; - protected _color: NotificationColor; + protected _symbol: string | null = null; + protected _count = 0; + protected _color: NotificationColor = NotificationColor.None; - public get symbol(): string { + private watcherReferences: string[] = []; + + constructor() { + super(); + this.watcherReferences.push( + SettingsStore.watchSetting("feature_hidebold", null, () => { + this.emit(NotificationStateEvents.Update); + }), + ); + } + + public get symbol(): string | null { return this._symbol; } @@ -58,7 +70,12 @@ export abstract class NotificationState } public get isUnread(): boolean { - return this.color >= NotificationColor.Bold; + if (this.color > NotificationColor.Bold) { + return true; + } else { + const hideBold = SettingsStore.getValue("feature_hidebold"); + return this.color === NotificationColor.Bold && !hideBold; + } } public get hasUnreadCount(): boolean { @@ -81,11 +98,15 @@ export abstract class NotificationState public destroy(): void { this.removeAllListeners(NotificationStateEvents.Update); + for (const watcherReference of this.watcherReferences) { + SettingsStore.unwatchSetting(watcherReference); + } + this.watcherReferences = []; } } export class NotificationStateSnapshot { - private readonly symbol: string; + private readonly symbol: string | null; private readonly count: number; private readonly color: NotificationColor; diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index dca3e290e3..559ae55de1 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -98,8 +98,8 @@ export class RoomNotificationState extends NotificationState implements IDestroy this.updateNotificationState(); }; - private handleRoomEventUpdate = (event: MatrixEvent, room: Room | null) => { - if (room?.roomId !== this.room.roomId) return; // ignore - not for us or notifications timeline + private handleRoomEventUpdate = (event: MatrixEvent) => { + if (event?.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline this.updateNotificationState(); }; diff --git a/src/stores/notifications/SpaceNotificationState.ts b/src/stores/notifications/SpaceNotificationState.ts index 241530f77f..0df920b566 100644 --- a/src/stores/notifications/SpaceNotificationState.ts +++ b/src/stores/notifications/SpaceNotificationState.ts @@ -32,7 +32,7 @@ export class SpaceNotificationState extends NotificationState { super(); } - public get symbol(): string { + public get symbol(): string | null { return this._color === NotificationColor.Unsent ? "!" : null; } diff --git a/src/stores/notifications/StaticNotificationState.ts b/src/stores/notifications/StaticNotificationState.ts index b18aa78e0f..fce8bee217 100644 --- a/src/stores/notifications/StaticNotificationState.ts +++ b/src/stores/notifications/StaticNotificationState.ts @@ -20,7 +20,7 @@ import { NotificationState } from "./NotificationState"; export class StaticNotificationState extends NotificationState { public static readonly RED_EXCLAMATION = StaticNotificationState.forSymbol("!", NotificationColor.Red); - constructor(symbol: string, count: number, color: NotificationColor) { + constructor(symbol: string | null, count: number, color: NotificationColor) { super(); this._symbol = symbol; this._count = count; diff --git a/src/toasts/BulkUnverifiedSessionsToast.ts b/src/toasts/BulkUnverifiedSessionsToast.ts index ae512df7ed..439d781126 100644 --- a/src/toasts/BulkUnverifiedSessionsToast.ts +++ b/src/toasts/BulkUnverifiedSessionsToast.ts @@ -20,6 +20,7 @@ import DeviceListener from '../DeviceListener'; import GenericToast from "../components/views/toasts/GenericToast"; import ToastStore from "../stores/ToastStore"; import { Action } from "../dispatcher/actions"; +import { snoozeBulkUnverifiedDeviceReminder } from '../utils/device/snoozeBulkUnverifiedDeviceReminder'; const TOAST_KEY = "reviewsessions"; @@ -34,6 +35,7 @@ export const showToast = (deviceIds: Set) => { const onReject = () => { DeviceListener.sharedInstance().dismissUnverifiedSessions(deviceIds); + snoozeBulkUnverifiedDeviceReminder(); }; ToastStore.sharedInstance().addOrReplaceToast({ diff --git a/src/utils/device/snoozeBulkUnverifiedDeviceReminder.ts b/src/utils/device/snoozeBulkUnverifiedDeviceReminder.ts new file mode 100644 index 0000000000..80f107b18a --- /dev/null +++ b/src/utils/device/snoozeBulkUnverifiedDeviceReminder.ts @@ -0,0 +1,40 @@ +/* +Copyright 2022 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 { logger } from "matrix-js-sdk/src/logger"; + +const SNOOZE_KEY = 'mx_snooze_bulk_unverified_device_nag'; +// one week +const snoozePeriod = 1000 * 60 * 60 * 24 * 7; +export const snoozeBulkUnverifiedDeviceReminder = () => { + try { + localStorage.setItem(SNOOZE_KEY, String(Date.now())); + } catch (error) { + logger.error('Failed to persist bulk unverified device nag snooze', error); + } +}; + +export const isBulkUnverifiedDeviceReminderSnoozed = () => { + try { + const snoozedTimestamp = localStorage.getItem(SNOOZE_KEY); + + const parsedTimestamp = Number.parseInt(snoozedTimestamp || '', 10); + + return Number.isInteger(parsedTimestamp) && (parsedTimestamp + snoozePeriod) > Date.now(); + } catch (error) { + return false; + } +}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx new file mode 100644 index 0000000000..4c6356ba2b --- /dev/null +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx @@ -0,0 +1,27 @@ +/* +Copyright 2022 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 React from "react"; + +import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; +import { _t } from "../../../languageHandler"; + +export const VoiceBroadcastRoomSubtitle = () => { + return
+ + { _t("Live") } +
; +}; diff --git a/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts b/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts new file mode 100644 index 0000000000..6db5ed789e --- /dev/null +++ b/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts @@ -0,0 +1,35 @@ +/* +Copyright 2022 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 { useState } from "react"; +import { Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; + +import { hasRoomLiveVoiceBroadcast } from "../utils/hasRoomLiveVoiceBroadcast"; +import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; + +export const useHasRoomLiveVoiceBroadcast = (room: Room) => { + const [hasLiveVoiceBroadcast, setHasLiveVoiceBroadcast] = useState(hasRoomLiveVoiceBroadcast(room).hasBroadcast); + + useTypedEventEmitter( + room.currentState, + RoomStateEvent.Update, + () => { + setHasLiveVoiceBroadcast(hasRoomLiveVoiceBroadcast(room).hasBroadcast); + }, + ); + + return hasLiveVoiceBroadcast; +}; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 21e1bdd4af..9bb2dfd4c0 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -29,12 +29,14 @@ export * from "./components/VoiceBroadcastBody"; export * from "./components/atoms/LiveBadge"; export * from "./components/atoms/VoiceBroadcastControl"; export * from "./components/atoms/VoiceBroadcastHeader"; +export * from "./components/atoms/VoiceBroadcastRoomSubtitle"; export * from "./components/molecules/VoiceBroadcastPlaybackBody"; export * from "./components/molecules/VoiceBroadcastPreRecordingPip"; export * from "./components/molecules/VoiceBroadcastRecordingBody"; export * from "./components/molecules/VoiceBroadcastRecordingPip"; export * from "./hooks/useCurrentVoiceBroadcastPreRecording"; export * from "./hooks/useCurrentVoiceBroadcastRecording"; +export * from "./hooks/useHasRoomLiveVoiceBroadcast"; export * from "./hooks/useVoiceBroadcastRecording"; export * from "./stores/VoiceBroadcastPlaybacksStore"; export * from "./stores/VoiceBroadcastPreRecordingStore"; diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts index 03ad29956e..20adbfd45d 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -35,6 +35,7 @@ import SettingsStore from "../src/settings/SettingsStore"; import { SettingLevel } from "../src/settings/SettingLevel"; import { getMockClientWithEventEmitter, mockPlatformPeg } from "./test-utils"; import { UIFeature } from "../src/settings/UIFeature"; +import { isBulkUnverifiedDeviceReminderSnoozed } from "../src/utils/device/snoozeBulkUnverifiedDeviceReminder"; // don't litter test console with logs jest.mock("matrix-js-sdk/src/logger"); @@ -48,6 +49,10 @@ jest.mock("../src/SecurityManager", () => ({ isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(), })); +jest.mock("../src/utils/device/snoozeBulkUnverifiedDeviceReminder", () => ({ + isBulkUnverifiedDeviceReminderSnoozed: jest.fn(), +})); + const userId = '@user:server'; const deviceId = 'my-device-id'; const mockDispatcher = mocked(dis); @@ -95,6 +100,7 @@ describe('DeviceListener', () => { }); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); + mocked(isBulkUnverifiedDeviceReminderSnoozed).mockClear().mockReturnValue(false); }); const createAndStart = async (): Promise => { @@ -451,6 +457,23 @@ describe('DeviceListener', () => { expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled(); }); + it('hides toast when reminder is snoozed', async () => { + mocked(isBulkUnverifiedDeviceReminderSnoozed).mockReturnValue(true); + // currentDevice, device2 are verified, device3 is unverified + mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + switch (deviceId) { + case currentDevice.deviceId: + case device2.deviceId: + return deviceTrustVerified; + default: + return deviceTrustUnverified; + } + }); + await createAndStart(); + expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled(); + expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled(); + }); + it('shows toast with unverified devices at app start', async () => { // currentDevice, device2 are verified, device3 is unverified mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { diff --git a/test/components/structures/auth/ForgotPassword-test.tsx b/test/components/structures/auth/ForgotPassword-test.tsx index 9f4b192aa9..97e26a1150 100644 --- a/test/components/structures/auth/ForgotPassword-test.tsx +++ b/test/components/structures/auth/ForgotPassword-test.tsx @@ -38,6 +38,7 @@ describe("", () => { let client: MatrixClient; let serverConfig: ValidatedServerConfig; let onComplete: () => void; + let onLoginClick: () => void; let renderResult: RenderResult; let restoreConsole: () => void; @@ -49,9 +50,16 @@ describe("", () => { }); }; - const submitForm = async (submitLabel: string): Promise => { + const clickButton = async (label: string): Promise => { await act(async () => { - await userEvent.click(screen.getByText(submitLabel), { delay: null }); + await userEvent.click(screen.getByText(label), { delay: null }); + }); + }; + + const itShouldCloseTheDialogAndShowThePasswordInput = (): void => { + it("should close the dialog and show the password input", () => { + expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument(); + expect(screen.getByText("Reset your password")).toBeInTheDocument(); }); }; @@ -70,6 +78,7 @@ describe("", () => { serverConfig.hsName = "example.com"; onComplete = jest.fn(); + onLoginClick = jest.fn(); jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue(serverConfig); jest.spyOn(AutoDiscoveryUtils, "authComponentStateForError"); @@ -94,6 +103,7 @@ describe("", () => { renderResult = render(); }); @@ -108,6 +118,7 @@ describe("", () => { renderResult.rerender(); }); @@ -116,6 +127,16 @@ describe("", () => { }); }); + describe("when clicking »Sign in instead«", () => { + beforeEach(async () => { + await clickButton("Sign in instead"); + }); + + it("should call onLoginClick()", () => { + expect(onLoginClick).toHaveBeenCalled(); + }); + }); + describe("when entering a non-email value", () => { beforeEach(async () => { await typeIntoField("Email address", "not en email"); @@ -132,7 +153,7 @@ describe("", () => { mocked(client).requestPasswordEmailToken.mockRejectedValue({ errcode: "M_THREEPID_NOT_FOUND", }); - await submitForm("Send email"); + await clickButton("Send email"); }); it("should show an email not found message", () => { @@ -146,7 +167,7 @@ describe("", () => { mocked(client).requestPasswordEmailToken.mockRejectedValue({ name: "ConnectionError", }); - await submitForm("Send email"); + await clickButton("Send email"); }); it("should show an info about that", () => { @@ -166,7 +187,7 @@ describe("", () => { serverIsAlive: false, serverDeadError: "server down", }); - await submitForm("Send email"); + await clickButton("Send email"); }); it("should show the server error", () => { @@ -180,7 +201,7 @@ describe("", () => { mocked(client).requestPasswordEmailToken.mockResolvedValue({ sid: testSid, }); - await submitForm("Send email"); + await clickButton("Send email"); }); it("should send the mail and show the check email view", () => { @@ -193,6 +214,16 @@ describe("", () => { expect(screen.getByText(testEmail)).toBeInTheDocument(); }); + describe("when clicking re-enter email", () => { + beforeEach(async () => { + await clickButton("Re-enter email address"); + }); + + it("go back to the email input", () => { + expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument(); + }); + }); + describe("when clicking resend email", () => { beforeEach(async () => { await userEvent.click(screen.getByText("Resend"), { delay: null }); @@ -212,7 +243,7 @@ describe("", () => { describe("when clicking next", () => { beforeEach(async () => { - await submitForm("Next"); + await clickButton("Next"); }); it("should show the password input view", () => { @@ -246,7 +277,7 @@ describe("", () => { retry_after_ms: (13 * 60 + 37) * 1000, }, }); - await submitForm("Reset password"); + await clickButton("Reset password"); }); it("should show the rate limit error message", () => { @@ -258,7 +289,7 @@ describe("", () => { describe("and submitting it", () => { beforeEach(async () => { - await submitForm("Reset password"); + await clickButton("Reset password"); // double flush promises for the modal to appear await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers(); @@ -284,6 +315,46 @@ describe("", () => { expect(screen.getByText(testEmail)).toBeInTheDocument(); }); + describe("and dismissing the dialog by clicking the background", () => { + beforeEach(async () => { + await act(async () => { + await userEvent.click(screen.getByTestId("dialog-background"), { delay: null }); + }); + // double flush promises for the modal to disappear + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + }); + + itShouldCloseTheDialogAndShowThePasswordInput(); + }); + + describe("and dismissing the dialog", () => { + beforeEach(async () => { + await act(async () => { + await userEvent.click(screen.getByLabelText("Close dialog"), { delay: null }); + }); + // double flush promises for the modal to disappear + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + }); + + itShouldCloseTheDialogAndShowThePasswordInput(); + }); + + describe("when clicking re-enter email", () => { + beforeEach(async () => { + await clickButton("Re-enter email address"); + // double flush promises for the modal to disappear + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + }); + + it("should close the dialog and go back to the email input", () => { + expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument(); + expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument(); + }); + }); + describe("when validating the link from the mail", () => { beforeEach(async () => { mocked(client.setPassword).mockResolvedValue({}); diff --git a/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx index 95d598a704..e0c503d6c5 100644 --- a/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx +++ b/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx @@ -20,6 +20,7 @@ import React from "react"; import { StatelessNotificationBadge, } from "../../../../../src/components/views/rooms/NotificationBadge/StatelessNotificationBadge"; +import SettingsStore from "../../../../../src/settings/SettingsStore"; import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor"; describe("NotificationBadge", () => { @@ -45,5 +46,19 @@ describe("NotificationBadge", () => { fireEvent.mouseLeave(container.firstChild); expect(cb).toHaveBeenCalledTimes(3); }); + + it("hides the bold icon when the settings is set", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + return name === "feature_hidebold"; + }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); }); }); diff --git a/test/components/views/rooms/RoomList-test.tsx b/test/components/views/rooms/RoomList-test.tsx index 6fa3fe22cf..cb5ddb1ffa 100644 --- a/test/components/views/rooms/RoomList-test.tsx +++ b/test/components/views/rooms/RoomList-test.tsx @@ -32,7 +32,7 @@ import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-l import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore"; import RoomList from "../../../../src/components/views/rooms/RoomList"; import RoomSublist from "../../../../src/components/views/rooms/RoomSublist"; -import RoomTile from "../../../../src/components/views/rooms/RoomTile"; +import { RoomTile } from "../../../../src/components/views/rooms/RoomTile"; import { getMockClientWithEventEmitter, mockClientMethodsUser } from '../../../test-utils'; import ResizeNotifier from '../../../../src/utils/ResizeNotifier'; diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index cf1ae59d09..4a3aa95937 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -15,12 +15,13 @@ limitations under the License. */ import React from "react"; -import { render, screen, act } from "@testing-library/react"; +import { render, screen, act, RenderResult } from "@testing-library/react"; import { mocked, Mocked } from "jest-mock"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { Widget } from "matrix-widget-api"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { ClientWidgetApi } from "matrix-widget-api"; @@ -30,6 +31,7 @@ import { MockedCall, useMockedCalls, setupAsyncStoreWithClient, + filterConsole, } from "../../../test-utils"; import { CallStore } from "../../../../src/stores/CallStore"; import RoomTile from "../../../../src/components/views/rooms/RoomTile"; @@ -39,38 +41,79 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import PlatformPeg from "../../../../src/PlatformPeg"; import BasePlatform from "../../../../src/BasePlatform"; import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; +import { VoiceBroadcastInfoState } from "../../../../src/voice-broadcast"; +import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; describe("RoomTile", () => { jest.spyOn(PlatformPeg, "get") .mockReturnValue({ overrideBrowserShortcuts: () => false } as unknown as BasePlatform); useMockedCalls(); + const setUpVoiceBroadcast = (state: VoiceBroadcastInfoState): void => { + voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent( + room.roomId, + state, + client.getUserId(), + client.getDeviceId(), + ); + + act(() => { + room.currentState.setStateEvents([voiceBroadcastInfoEvent]); + }); + }; + + const renderRoomTile = (): void => { + renderResult = render( + , + ); + }; + let client: Mocked; + let restoreConsole: () => void; + let voiceBroadcastInfoEvent: MatrixEvent; + let room: Room; + let renderResult: RenderResult; beforeEach(() => { + restoreConsole = filterConsole( + // irrelevant for this test + "Room !1:example.org does not have an m.room.create event", + ); + stubClient(); client = mocked(MatrixClientPeg.get()); DMRoomMap.makeShared(); + + room = new Room("!1:example.org", client, "@alice:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + + renderRoomTile(); }); afterEach(() => { + restoreConsole(); jest.clearAllMocks(); }); - describe("call subtitle", () => { - let room: Room; + it("should render the room", () => { + expect(renderResult.container).toMatchSnapshot(); + }); + + describe("when a call starts", () => { let call: MockedCall; let widget: Widget; beforeEach(() => { - room = new Room("!1:example.org", client, "@alice:example.org", { - pendingEventOrdering: PendingEventOrdering.Detached, - }); - - client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); - client.getRooms.mockReturnValue([room]); - client.reEmitter.reEmit(room, [RoomStateEvent.Events]); - setupAsyncStoreWithClient(CallStore.instance, client); setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); @@ -83,18 +126,10 @@ describe("RoomTile", () => { WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, } as unknown as ClientWidgetApi); - - render( - , - ); }); afterEach(() => { + renderResult.unmount(); call.destroy(); client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); @@ -147,5 +182,45 @@ describe("RoomTile", () => { act(() => { call.participants = new Map(); }); expect(screen.queryByLabelText(/participant/)).toBe(null); }); + + describe("and a live broadcast starts", () => { + beforeEach(() => { + setUpVoiceBroadcast(VoiceBroadcastInfoState.Started); + }); + + it("should still render the call subtitle", () => { + expect(screen.queryByText("Video")).toBeInTheDocument(); + expect(screen.queryByText("Live")).not.toBeInTheDocument(); + }); + }); + }); + + describe("when a live voice broadcast starts", () => { + beforeEach(() => { + setUpVoiceBroadcast(VoiceBroadcastInfoState.Started); + }); + + it("should render the »Live« subtitle", () => { + expect(screen.queryByText("Live")).toBeInTheDocument(); + }); + + describe("and the broadcast stops", () => { + beforeEach(() => { + const stopEvent = mkVoiceBroadcastInfoStateEvent( + room.roomId, + VoiceBroadcastInfoState.Stopped, + client.getUserId(), + client.getDeviceId(), + voiceBroadcastInfoEvent, + ); + act(() => { + room.currentState.setStateEvents([stopEvent]); + }); + }); + + it("should not render the »Live« subtitle", () => { + expect(screen.queryByText("Live")).not.toBeInTheDocument(); + }); + }); }); }); diff --git a/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap new file mode 100644 index 0000000000..b4114bcb53 --- /dev/null +++ b/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RoomTile should render the room 1`] = ` +
+
+
+ + + + +
+
+
+ + !1:​example.org + +
+
+ + +`; diff --git a/test/components/views/spaces/QuickThemeSwitcher-test.tsx b/test/components/views/spaces/QuickThemeSwitcher-test.tsx index 4efa1473b2..28a0e3e954 100644 --- a/test/components/views/spaces/QuickThemeSwitcher-test.tsx +++ b/test/components/views/spaces/QuickThemeSwitcher-test.tsx @@ -38,6 +38,7 @@ jest.mock('../../../../src/settings/SettingsStore', () => ({ setValue: jest.fn(), getValue: jest.fn(), monitorSetting: jest.fn(), + watchSetting: jest.fn(), })); jest.mock('../../../../src/dispatcher/dispatcher', () => ({ diff --git a/test/stores/TypingStore-test.ts b/test/stores/TypingStore-test.ts index a5b4437f14..b6b5c388f8 100644 --- a/test/stores/TypingStore-test.ts +++ b/test/stores/TypingStore-test.ts @@ -25,6 +25,7 @@ import { TestSdkContext } from "../TestSdkContext"; jest.mock("../../src/settings/SettingsStore", () => ({ getValue: jest.fn(), monitorSetting: jest.fn(), + watchSetting: jest.fn(), })); describe("TypingStore", () => { diff --git a/test/test-utils/console.ts b/test/test-utils/console.ts index ff1ea0be09..f73c42568a 100644 --- a/test/test-utils/console.ts +++ b/test/test-utils/console.ts @@ -39,7 +39,7 @@ export const filterConsole = (...ignoreList: string[]): () => void => { return; } - originalFunction(data); + originalFunction(...data); }; } diff --git a/test/utils/MultiInviter-test.ts b/test/utils/MultiInviter-test.ts index 83b71232fc..49c2ebbeaf 100644 --- a/test/utils/MultiInviter-test.ts +++ b/test/utils/MultiInviter-test.ts @@ -42,6 +42,7 @@ jest.mock('../../src/Modal', () => ({ jest.mock('../../src/settings/SettingsStore', () => ({ getValue: jest.fn(), monitorSetting: jest.fn(), + watchSetting: jest.fn(), })); const mockPromptBeforeInviteUnknownUsers = (value: boolean) => { diff --git a/test/utils/device/snoozeBulkUnverifiedDeviceReminder-test.ts b/test/utils/device/snoozeBulkUnverifiedDeviceReminder-test.ts new file mode 100644 index 0000000000..e7abf4b56a --- /dev/null +++ b/test/utils/device/snoozeBulkUnverifiedDeviceReminder-test.ts @@ -0,0 +1,98 @@ +/* +Copyright 2022 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 { logger } from "matrix-js-sdk/src/logger"; + +import { + isBulkUnverifiedDeviceReminderSnoozed, + snoozeBulkUnverifiedDeviceReminder, +} from "../../../src/utils/device/snoozeBulkUnverifiedDeviceReminder"; + +const SNOOZE_KEY = 'mx_snooze_bulk_unverified_device_nag'; + +describe('snooze bulk unverified device nag', () => { + const localStorageSetSpy = jest.spyOn(localStorage.__proto__, 'setItem'); + const localStorageGetSpy = jest.spyOn(localStorage.__proto__, 'getItem'); + const localStorageRemoveSpy = jest.spyOn(localStorage.__proto__, 'removeItem'); + + // 14.03.2022 16:15 + const now = 1647270879403; + + beforeEach(() => { + localStorageSetSpy.mockClear().mockImplementation(() => {}); + localStorageGetSpy.mockClear().mockReturnValue(null); + localStorageRemoveSpy.mockClear().mockImplementation(() => {}); + + jest.spyOn(Date, 'now').mockReturnValue(now); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('snoozeBulkUnverifiedDeviceReminder()', () => { + it('sets the current time in local storage', () => { + snoozeBulkUnverifiedDeviceReminder(); + + expect(localStorageSetSpy).toHaveBeenCalledWith(SNOOZE_KEY, now.toString()); + }); + + it('catches an error from localstorage', () => { + const loggerErrorSpy = jest.spyOn(logger, 'error'); + localStorageSetSpy.mockImplementation(() => { throw new Error('oups'); }); + snoozeBulkUnverifiedDeviceReminder(); + expect(loggerErrorSpy).toHaveBeenCalled(); + }); + }); + + describe('isBulkUnverifiedDeviceReminderSnoozed()', () => { + it('returns false when there is no snooze in storage', () => { + const result = isBulkUnverifiedDeviceReminderSnoozed(); + expect(localStorageGetSpy).toHaveBeenCalledWith(SNOOZE_KEY); + expect(result).toBe(false); + }); + + it('catches an error from localstorage and returns false', () => { + const loggerErrorSpy = jest.spyOn(logger, 'error'); + localStorageGetSpy.mockImplementation(() => { throw new Error('oups'); }); + const result = isBulkUnverifiedDeviceReminderSnoozed(); + expect(result).toBe(false); + expect(loggerErrorSpy).toHaveBeenCalled(); + }); + + it('returns false when snooze timestamp in storage is not a number', () => { + localStorageGetSpy.mockReturnValue('test'); + const result = isBulkUnverifiedDeviceReminderSnoozed(); + expect(result).toBe(false); + }); + + it('returns false when snooze timestamp in storage is over a week ago', () => { + const msDay = 1000 * 60 * 60 * 24; + // snoozed 8 days ago + localStorageGetSpy.mockReturnValue(now - (msDay * 8)); + const result = isBulkUnverifiedDeviceReminderSnoozed(); + expect(result).toBe(false); + }); + + it('returns true when snooze timestamp in storage is less than a week ago', () => { + const msDay = 1000 * 60 * 60 * 24; + // snoozed 8 days ago + localStorageGetSpy.mockReturnValue(now - (msDay * 6)); + const result = isBulkUnverifiedDeviceReminderSnoozed(); + expect(result).toBe(true); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index bd158677a6..89aa9ee0ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2267,11 +2267,6 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== -"@types/sdp-transform@^2.4.5": - version "2.4.5" - resolved "https://registry.yarnpkg.com/@types/sdp-transform/-/sdp-transform-2.4.5.tgz#3167961e0a1a5265545e278627aa37c606003f53" - integrity sha512-GVO0gnmbyO3Oxm2HdPsYUNcyihZE3GyCY8ysMYHuQGfLhGZq89Nm4lSzULWTzZoyHtg+VO/IdrnxZHPnPSGnAg== - "@types/semver@^7.3.12": version "7.3.13" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" @@ -6357,11 +6352,10 @@ matrix-events-sdk@0.0.1: integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "21.2.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/1606274c36008b6a976a5e4b47cdd13a1e4e5997" + version "22.0.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ccab6985ad5567960fa9bc4cd95fc39241560b80" dependencies: "@babel/runtime" "^7.12.5" - "@types/sdp-transform" "^2.4.5" another-json "^0.2.0" bs58 "^5.0.0" content-type "^1.0.4" @@ -6372,6 +6366,7 @@ matrix-events-sdk@0.0.1: qs "^6.9.6" sdp-transform "^2.14.1" unhomoglyph "^1.0.6" + uuid "7" matrix-mock-request@^2.5.0: version "2.6.0" @@ -8486,6 +8481,11 @@ util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +uuid@7: + version "7.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" + integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== + uuid@8.3.2, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"