diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml index 45fec0d008..c915412e02 100644 --- a/.github/workflows/test_coverage.yml +++ b/.github/workflows/test_coverage.yml @@ -16,7 +16,7 @@ jobs: # If this is a pull request, make sure we check out its head rather than the # automatically generated merge commit, so that the coverage diff excludes # unrelated changes in the base branch - ref: ${{ github.event.type == 'PullRequestEvent' && github.event.pull_request.head.sha || '' }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} - name: Yarn cache uses: c-hive/gha-yarn-cache@v2 diff --git a/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss b/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss index 0ee60a65f2..04645c965e 100644 --- a/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss +++ b/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss @@ -15,6 +15,7 @@ limitations under the License. */ .mx_LeftPanelLiveShareWarning { + @mixin ButtonResetDefault; width: 100%; box-sizing: border-box; @@ -29,3 +30,7 @@ limitations under the License. // go above to get hover for title z-index: 1; } + +.mx_LeftPanelLiveShareWarning__error { + background-color: $alert; +} diff --git a/res/css/components/views/beacon/_RoomLiveShareWarning.scss b/res/css/components/views/beacon/_RoomLiveShareWarning.scss index c0d5ea47fe..7404f88aea 100644 --- a/res/css/components/views/beacon/_RoomLiveShareWarning.scss +++ b/res/css/components/views/beacon/_RoomLiveShareWarning.scss @@ -48,3 +48,13 @@ limitations under the License. .mx_RoomLiveShareWarning_spinner { margin-right: $spacing-16; } + +.mx_RoomLiveShareWarning_closeButton { + @mixin ButtonResetDefault; + margin-left: $spacing-16; +} + +.mx_RoomLiveShareWarning_closeButtonIcon { + height: $font-18px; + padding: $spacing-4; +} diff --git a/res/css/components/views/beacon/_StyledLiveBeaconIcon.scss b/res/css/components/views/beacon/_StyledLiveBeaconIcon.scss index 2ff597efc3..e31279c34b 100644 --- a/res/css/components/views/beacon/_StyledLiveBeaconIcon.scss +++ b/res/css/components/views/beacon/_StyledLiveBeaconIcon.scss @@ -28,3 +28,8 @@ limitations under the License. // colors icon color: white; } + +.mx_StyledLiveBeaconIcon.mx_StyledLiveBeaconIcon_error { + background-color: $alert; + border-color: $alert; +} diff --git a/res/css/views/audio_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss index 408a295389..4999980bea 100644 --- a/res/css/views/audio_messages/_PlaybackContainer.scss +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -29,6 +29,7 @@ limitations under the License. contain: content; + // Waveforms are present in live recording only .mx_Waveform { .mx_Waveform_bar { background-color: $quaternary-content; @@ -46,11 +47,22 @@ limitations under the License. .mx_Clock { width: $font-42px; // we're not using a monospace font, so fake it + min-width: $font-42px; // force sensible layouts in awkward flexboxes (file panel, for example) padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended. padding-left: 8px; // isolate from recording circle / play control } - &.mx_VoiceMessagePrimaryContainer_noWaveform { - max-width: 162px; // with all the padding this results in 185px wide + // For timeline-rendered playback, mirror the values for where the clock is in + // the waveform version. + .mx_SeekBar { + margin-left: 8px; + margin-right: 6px; + + & + .mx_Clock { + text-align: right; + + // Take the padding off the clock because it's accounted for in the seek bar + padding: 0; + } } } diff --git a/res/css/views/right_panel/_TimelineCard.scss b/res/css/views/right_panel/_TimelineCard.scss index 16cf06dac5..349654886d 100644 --- a/res/css/views/right_panel/_TimelineCard.scss +++ b/res/css/views/right_panel/_TimelineCard.scss @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -35,6 +35,12 @@ limitations under the License. } } + .mx_TimelineCard_timeline { + overflow: hidden; + position: relative; // offset parent for jump to bottom button + flex: 1; + } + .mx_AutoHideScrollbar { padding-right: 10px; width: calc(100% - 10px); @@ -119,8 +125,3 @@ limitations under the License. flex-basis: 48px; // 12 (padding on message list) + 36 (padding on event lines) } } - -.mx_TimelineCard_timeline { - overflow: hidden; - position: relative; // offset parent for jump to bottom button -} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 310465837b..bb60c95c99 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -970,6 +970,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); .mx_EventTile_content, .mx_HiddenBody, .mx_RedactedBody, + .mx_UnknownBody, .mx_MPollBody, .mx_ReplyChain_wrapper { margin-left: 36px; diff --git a/res/css/views/rooms/_RoomPreviewBar.scss b/res/css/views/rooms/_RoomPreviewBar.scss index 8e4a9ee575..7cce08c789 100644 --- a/res/css/views/rooms/_RoomPreviewBar.scss +++ b/res/css/views/rooms/_RoomPreviewBar.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015 - 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. @@ -98,6 +98,14 @@ limitations under the License. } } +// With maximised widgets, the panel fits in better when rounded +.mx_MainSplit_maximisedWidget .mx_RoomPreviewBar_panel { + margin: $container-gap-width; + margin-right: calc($container-gap-width / 2); // Shared with right panel + margin-top: 0; // Already covered by apps drawer + border-radius: 8px; +} + .mx_RoomPreviewBar_dialog { margin: auto; box-sizing: content; diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 884943608f..fe91688101 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -49,6 +49,7 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog"; import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog"; import { createThumbnail } from "./utils/image-media"; +import { attachRelation } from "./components/views/rooms/SendMessageComposer"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -147,15 +148,20 @@ async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imag const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType); const imageInfo = result.info; - // we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from. - const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size; - if ( - imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already - (sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original - sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT)) - ) { - delete imageInfo["thumbnail_info"]; - return imageInfo; + // For lesser supported image types, always include the thumbnail even if it is larger + if (!["image/avif", "image/webp"].includes(imageFile.type)) { + // we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from. + const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size; + if ( + // image is small enough already + imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || + // thumbnail is not sufficiently smaller than original + (sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && + sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT)) + ) { + delete imageInfo["thumbnail_info"]; + return imageInfo; + } } const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail); @@ -474,10 +480,7 @@ export default class ContentMessages { msgtype: "", // set later }; - if (relation) { - content["m.relates_to"] = relation; - } - + attachRelation(content, relation); if (replyToEvent) { addReplyToMessageContent(content, replyToEvent, { includeLegacyFallback: false, diff --git a/src/Editing.ts b/src/Editing.ts new file mode 100644 index 0000000000..57e58cc2a7 --- /dev/null +++ b/src/Editing.ts @@ -0,0 +1,20 @@ +/* +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 { TimelineRenderingType } from "./contexts/RoomContext"; + +export const editorRoomKey = (roomId: string, context: TimelineRenderingType) => `mx_edit_room_${roomId}_${context}`; +export const editorStateKey = (eventId: string) => `mx_edit_state_${eventId}`; diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index 8abfc5ce39..eb0c4f6ba8 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -126,7 +126,7 @@ export function showAnyInviteErrors( // user. This usually means that no other users were attempted, making it // pointless for us to list who failed exactly. Modal.createTrackedDialog('Failed to invite users to the room', '', ErrorDialog, { - title: _t("Failed to invite users to the room:", { roomName: room.name }), + title: _t("Failed to invite users to %(roomName)s", { roomName: room.name }), description: inviter.getErrorText(failedUsers[0]), }); return false; diff --git a/src/Unread.ts b/src/Unread.ts index 95046db29d..e596bdbe50 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -21,6 +21,8 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { MatrixClientPeg } from "./MatrixClientPeg"; import shouldHideEvent from './shouldHideEvent'; import { haveRendererForEvent } from "./events/EventTileFactory"; +import SettingsStore from "./settings/SettingsStore"; +import { RoomNotificationStateStore } from "./stores/notifications/RoomNotificationStateStore"; /** * Returns true if this event arriving in a room should affect the room's @@ -57,14 +59,21 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { // despite the name of the method :(( const readUpToId = room.getEventReadUpTo(myUserId); - // as we don't send RRs for our own messages, make sure we special case that - // if *we* sent the last message into the room, we consider it not unread! - // Should fix: https://github.com/vector-im/element-web/issues/3263 - // https://github.com/vector-im/element-web/issues/2427 - // ...and possibly some of the others at - // https://github.com/vector-im/element-web/issues/3363 - if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) { - return false; + if (!SettingsStore.getValue("feature_thread")) { + // as we don't send RRs for our own messages, make sure we special case that + // if *we* sent the last message into the room, we consider it not unread! + // Should fix: https://github.com/vector-im/element-web/issues/3263 + // https://github.com/vector-im/element-web/issues/2427 + // ...and possibly some of the others at + // https://github.com/vector-im/element-web/issues/3363 + if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) { + return false; + } + } else { + const threadState = RoomNotificationStateStore.instance.getThreadsRoomState(room); + if (threadState.color > 0) { + return true; + } } // if the read receipt relates to an event is that part of a thread diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 4f7a6ca724..781bdb282c 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -53,6 +53,7 @@ import { Action } from '../../dispatcher/actions'; import { getEventDisplayInfo } from "../../utils/EventRenderingUtils"; import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker"; import { haveRendererForEvent } from "../../events/EventTileFactory"; +import { editorRoomKey } from "../../Editing"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -306,9 +307,10 @@ export default class MessagePanel extends React.Component { const pendingEditItem = this.pendingEditItem; if (!this.props.editState && this.props.room && pendingEditItem) { + const event = this.props.room.findEventById(pendingEditItem); defaultDispatcher.dispatch({ action: Action.EditEvent, - event: this.props.room.findEventById(pendingEditItem), + event: !event?.isRedacted() ? event : null, timelineRenderingType: this.context.timelineRenderingType, }); } @@ -612,13 +614,15 @@ export default class MessagePanel extends React.Component { if (!this.props.room) { return undefined; } + try { - return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}_${this.context.timelineRenderingType}`); + return localStorage.getItem(editorRoomKey(this.props.room.roomId, this.context.timelineRenderingType)); } catch (err) { logger.error(err); return undefined; } } + private getEventTiles(): ReactNode[] { let i; @@ -721,10 +725,8 @@ export default class MessagePanel extends React.Component { ): ReactNode[] { const ret = []; - const isEditing = this.props.editState && - this.props.editState.getEvent().getId() === mxEv.getId(); - // local echoes have a fake date, which could even be yesterday. Treat them - // as 'today' for the date separators. + const isEditing = this.props.editState?.getEvent().getId() === mxEv.getId(); + // local echoes have a fake date, which could even be yesterday. Treat them as 'today' for the date separators. let ts1 = mxEv.getTs(); let eventDate = mxEv.getDate(); if (mxEv.status) { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index d57f1651d2..c788c64893 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -364,7 +364,7 @@ export class RoomView extends React.Component { this.checkWidgets(this.state.room); }; - private checkWidgets = (room) => { + private checkWidgets = (room: Room): void => { this.setState({ hasPinnedWidgets: WidgetLayoutStore.instance.hasPinnedWidgets(room), mainSplitContentType: this.getMainSplitContentType(room), @@ -372,7 +372,7 @@ export class RoomView extends React.Component { }); }; - private getMainSplitContentType = (room) => { + private getMainSplitContentType = (room: Room) => { if (SettingsStore.getValue("feature_voice_rooms") && room.isCallRoom()) { return MainSplitContentType.Video; } @@ -1981,11 +1981,11 @@ export class RoomView extends React.Component { ); let messageComposer; let searchInfo; - const canSpeak = ( + const showComposer = ( // joined and not showing search results myMembership === 'join' && !this.state.searchResults ); - if (canSpeak) { + if (showComposer) { messageComposer = { const showChatEffects = SettingsStore.getValue('showChatEffects'); - let mainSplitBody; + let mainSplitBody: React.ReactFragment; + let mainSplitContentClassName: string; // Decide what to show in the main split switch (this.state.mainSplitContentType) { case MainSplitContentType.Timeline: + mainSplitContentClassName = "mx_MainSplit_timeline"; mainSplitBody = <> { ; break; case MainSplitContentType.MaximisedWidget: - mainSplitBody = ; + mainSplitContentClassName = "mx_MainSplit_maximisedWidget"; + mainSplitBody = <> + + { previewBar } + ; break; case MainSplitContentType.Video: { const app = getVoiceChannel(this.state.room.roomId); if (!app) break; + mainSplitContentClassName = "mx_MainSplit_video"; mainSplitBody = { />; } } + const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName); + let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline]; let onAppsClick = this.onAppsClick; let onForgetClick = this.onForgetClick; @@ -2160,6 +2169,7 @@ export class RoomView extends React.Component { onForgetClick = null; onSearchClick = null; } + return (
@@ -2181,7 +2191,7 @@ export class RoomView extends React.Component { excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} /> -
+
{ mainSplitBody }
diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 600426a8b8..5e4da890b8 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -50,6 +50,7 @@ import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import Measured from '../views/elements/Measured'; import PosthogTrackers from "../../PosthogTrackers"; import { ButtonEvent } from "../views/elements/AccessibleButton"; +import RoomViewStore from '../../stores/RoomViewStore'; interface IProps { room: Room; @@ -104,9 +105,19 @@ export default class ThreadView extends React.Component { public componentWillUnmount(): void { this.teardownThread(); if (this.dispatcherRef) dis.unregister(this.dispatcherRef); - const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const roomId = this.props.mxEvent.getRoomId(); + const room = MatrixClientPeg.get().getRoom(roomId); room.removeListener(ThreadEvent.New, this.onNewThread); SettingsStore.unwatchSetting(this.layoutWatcherRef); + + const hasRoomChanged = RoomViewStore.getRoomId() !== roomId; + if (this.props.isInitialEventHighlighted && !hasRoomChanged) { + dis.dispatch({ + action: Action.ViewRoom, + room_id: this.props.room.roomId, + metricsTrigger: undefined, // room doesn't change + }); + } } public componentDidUpdate(prevProps) { @@ -204,7 +215,7 @@ export default class ThreadView extends React.Component { } }; - private onScroll = (): void => { + private resetHighlightedEvent = (): void => { if (this.props.initialEvent && this.props.isInitialEventHighlighted) { dis.dispatch({ action: Action.ViewRoom, @@ -361,7 +372,7 @@ export default class ThreadView extends React.Component { editState={this.state.editState} eventId={this.props.initialEvent?.getId()} highlightedEventId={highlightedEventId} - onUserScroll={this.onScroll} + onUserScroll={this.resetHighlightedEvent} onPaginationRequest={this.onPaginationRequest} />
} diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx index 26055d1bb8..c681b366cf 100644 --- a/src/components/views/audio_messages/AudioPlayer.tsx +++ b/src/components/views/audio_messages/AudioPlayer.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef, ReactNode, RefObject } from "react"; +import React, { ReactNode } from "react"; import PlayPauseButton from "./PlayPauseButton"; import { formatBytes } from "../../../utils/FormattingUtils"; @@ -23,40 +23,8 @@ import { _t } from "../../../languageHandler"; import SeekBar from "./SeekBar"; import PlaybackClock from "./PlaybackClock"; import AudioPlayerBase from "./AudioPlayerBase"; -import { getKeyBindingsManager } from "../../../KeyBindingsManager"; -import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; export default class AudioPlayer extends AudioPlayerBase { - private playPauseRef: RefObject = createRef(); - private seekRef: RefObject = createRef(); - - private onKeyDown = (ev: React.KeyboardEvent) => { - let handled = true; - const action = getKeyBindingsManager().getAccessibilityAction(ev); - - switch (action) { - case KeyBindingAction.Space: - this.playPauseRef.current?.toggleState(); - break; - case KeyBindingAction.ArrowLeft: - this.seekRef.current?.left(); - break; - case KeyBindingAction.ArrowRight: - this.seekRef.current?.right(); - break; - default: - handled = false; - break; - } - - // stopPropagation() prevents the FocusComposer catch-all from triggering, - // but we need to do it on key down instead of press (even though the user - // interaction is typically on press). - if (handled) { - ev.stopPropagation(); - } - }; - protected renderFileSize(): string { const bytes = this.props.playback.sizeBytes; if (!bytes) return null; diff --git a/src/components/views/audio_messages/AudioPlayerBase.tsx b/src/components/views/audio_messages/AudioPlayerBase.tsx index 52b9a79214..9b3a7e4cef 100644 --- a/src/components/views/audio_messages/AudioPlayerBase.tsx +++ b/src/components/views/audio_messages/AudioPlayerBase.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -14,14 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode } from "react"; +import React, { createRef, ReactNode, RefObject } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { Playback, PlaybackState } from "../../../audio/Playback"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { _t } from "../../../languageHandler"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import SeekBar from "./SeekBar"; +import PlayPauseButton from "./PlayPauseButton"; -interface IProps { +export interface IProps { // Playback instance to render. Cannot change during component lifecycle: create // an all-new component instead. playback: Playback; @@ -34,8 +38,11 @@ interface IState { error?: boolean; } -export default abstract class AudioPlayerBase extends React.PureComponent { - constructor(props: IProps) { +export default abstract class AudioPlayerBase extends React.PureComponent { + protected seekRef: RefObject = createRef(); + protected playPauseRef: RefObject = createRef(); + + constructor(props: T) { super(props); // Playback instances can be reused in the composer @@ -54,6 +61,33 @@ export default abstract class AudioPlayerBase extends React.PureComponent { + let handled = true; + const action = getKeyBindingsManager().getAccessibilityAction(ev); + + switch (action) { + case KeyBindingAction.Space: + this.playPauseRef.current?.toggleState(); + break; + case KeyBindingAction.ArrowLeft: + this.seekRef.current?.left(); + break; + case KeyBindingAction.ArrowRight: + this.seekRef.current?.right(); + break; + default: + handled = false; + break; + } + + // stopPropagation() prevents the FocusComposer catch-all from triggering, + // but we need to do it on key down instead of press (even though the user + // interaction is typically on press). + if (handled) { + ev.stopPropagation(); + } + }; + private onPlaybackUpdate = (ev: PlaybackState) => { this.setState({ playbackPhase: ev }); }; diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx index 7440a6c57e..f9e8405958 100644 --- a/src/components/views/audio_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -18,28 +18,49 @@ import React, { ReactNode } from "react"; import PlayPauseButton from "./PlayPauseButton"; import PlaybackClock from "./PlaybackClock"; +import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase"; +import SeekBar from "./SeekBar"; import PlaybackWaveform from "./PlaybackWaveform"; -import AudioPlayerBase from "./AudioPlayerBase"; -import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; -export default class RecordingPlayback extends AudioPlayerBase { - static contextType = RoomContext; - public context!: React.ContextType; +interface IProps extends IAudioPlayerBaseProps { + /** + * When true, use a waveform instead of a seek bar + */ + withWaveform?: boolean; +} - private get isWaveformable(): boolean { - return this.context.timelineRenderingType !== TimelineRenderingType.Notification - && this.context.timelineRenderingType !== TimelineRenderingType.File - && this.context.timelineRenderingType !== TimelineRenderingType.Pinned; +export default class RecordingPlayback extends AudioPlayerBase { + // This component is rendered in two ways: the composer and timeline. They have different + // rendering properties (specifically the difference of a waveform or not). + + private renderWaveformLook(): ReactNode { + return <> + + + ; + } + + private renderSeekableLook(): ReactNode { + return <> + + + ; } protected renderComponent(): ReactNode { - const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : ''; - return ( -
- - - { this.isWaveformable && } +
+ + { this.props.withWaveform ? this.renderWaveformLook() : this.renderSeekableLook() }
); } diff --git a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx index 2b4b7eb70e..07ba4cd236 100644 --- a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx +++ b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx @@ -21,11 +21,31 @@ import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { _t } from '../../../languageHandler'; import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore'; import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg'; +import { ViewRoomPayload } from '../../../dispatcher/payloads/ViewRoomPayload'; +import { Action } from '../../../dispatcher/actions'; +import dispatcher from '../../../dispatcher/dispatcher'; +import AccessibleButton from '../elements/AccessibleButton'; interface Props { isMinimized?: boolean; } +/** + * Choose the most relevant beacon + * and get its roomId + */ +const chooseBestBeaconRoomId = (liveBeaconIds, errorBeaconIds): string | undefined => { + // both lists are ordered by creation timestamp in store + // so select latest beacon + const beaconId = errorBeaconIds?.[0] ?? liveBeaconIds?.[0]; + if (!beaconId) { + return undefined; + } + const beacon = OwnBeaconStore.instance.getBeaconById(beaconId); + + return beacon?.roomId; +}; + const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { const isMonitoringLiveLocation = useEventEmitterState( OwnBeaconStore.instance, @@ -33,18 +53,48 @@ const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { () => OwnBeaconStore.instance.isMonitoringLiveLocation, ); + const beaconIdsWithWireError = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.WireError, + () => OwnBeaconStore.instance.getLiveBeaconIdsWithWireError(), + ); + + const liveBeaconIds = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.LivenessChange, + () => OwnBeaconStore.instance.getLiveBeaconIds(), + ); + + const hasWireErrors = !!beaconIdsWithWireError.length; + if (!isMonitoringLiveLocation) { return null; } - return
{ + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: relevantBeaconRoomId, + metricsTrigger: undefined, + }); + } : undefined; + + const label = hasWireErrors ? + _t('An error occured whilst sharing your live location') : + _t('You are sharing your live location'); + + return - { isMinimized ? : _t('You are sharing your live location') } -
; + { isMinimized ? : label } + ; }; export default LeftPanelLiveShareWarning; diff --git a/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index 24c0b345cc..d51c22f644 100644 --- a/src/components/views/beacon/RoomLiveShareWarning.tsx +++ b/src/components/views/beacon/RoomLiveShareWarning.tsx @@ -18,19 +18,16 @@ import React, { useCallback, useEffect, useState } from 'react'; import classNames from 'classnames'; import { Room, Beacon } from 'matrix-js-sdk/src/matrix'; +import { formatDuration } from '../../../DateUtils'; import { _t } from '../../../languageHandler'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; -import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore'; -import AccessibleButton from '../elements/AccessibleButton'; -import StyledLiveBeaconIcon from './StyledLiveBeaconIcon'; -import { formatDuration } from '../../../DateUtils'; -import { getBeaconMsUntilExpiry, sortBeaconsByLatestExpiry } from '../../../utils/beacon'; -import Spinner from '../elements/Spinner'; import { useInterval } from '../../../hooks/useTimeout'; - -interface Props { - roomId: Room['roomId']; -} +import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore'; +import { getBeaconMsUntilExpiry, sortBeaconsByLatestExpiry } from '../../../utils/beacon'; +import AccessibleButton from '../elements/AccessibleButton'; +import Spinner from '../elements/Spinner'; +import StyledLiveBeaconIcon from './StyledLiveBeaconIcon'; +import { Icon as CloseIcon } from '../../../../res/img/image-view/close.svg'; const MINUTE_MS = 60000; const HOUR_MS = MINUTE_MS * 60; @@ -72,33 +69,28 @@ const useMsRemaining = (beacon: Beacon): number => { type LiveBeaconsState = { beacon?: Beacon; onStopSharing?: () => void; + onResetWireError?: () => void; stoppingInProgress?: boolean; + hasStopSharingError?: boolean; + hasWireError?: boolean; }; -const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => { +const useLiveBeacons = (liveBeaconIds: string[], roomId: string): LiveBeaconsState => { const [stoppingInProgress, setStoppingInProgress] = useState(false); + const [error, setError] = useState(); - // do we have an active geolocation.watchPosition - const isMonitoringLiveLocation = useEventEmitterState( + const hasWireError = useEventEmitterState( OwnBeaconStore.instance, - OwnBeaconStoreEvent.MonitoringLivePosition, - () => OwnBeaconStore.instance.isMonitoringLiveLocation, - ); - - const liveBeaconIds = useEventEmitterState( - OwnBeaconStore.instance, - OwnBeaconStoreEvent.LivenessChange, - () => OwnBeaconStore.instance.getLiveBeaconIds(roomId), + OwnBeaconStoreEvent.WireError, + () => + OwnBeaconStore.instance.hasWireErrors(roomId), ); // reset stopping in progress on change in live ids useEffect(() => { setStoppingInProgress(false); + setError(undefined); }, [liveBeaconIds]); - if (!isMonitoringLiveLocation || !liveBeaconIds?.length) { - return {}; - } - // select the beacon with latest expiry to display expiry time const beacon = liveBeaconIds.map(beaconId => OwnBeaconStore.instance.getBeaconById(beaconId)) .sort(sortBeaconsByLatestExpiry) @@ -112,11 +104,23 @@ const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => { // only clear loading in case of error // to avoid flash of not-loading state // after beacons have been stopped but we wait for sync + setError(error); setStoppingInProgress(false); } }; - return { onStopSharing, beacon, stoppingInProgress }; + const onResetWireError = () => { + liveBeaconIds.map(beaconId => OwnBeaconStore.instance.resetWireError(beaconId)); + }; + + return { + onStopSharing, + onResetWireError, + beacon, + stoppingInProgress, + hasWireError, + hasStopSharingError: !!error, + }; }; const LiveTimeRemaining: React.FC<{ beacon: Beacon }> = ({ beacon }) => { @@ -131,39 +135,103 @@ const LiveTimeRemaining: React.FC<{ beacon: Beacon }> = ({ beacon }) => { >{ liveTimeRemaining }; }; -const RoomLiveShareWarning: React.FC = ({ roomId }) => { +const getLabel = (hasWireError: boolean, hasStopSharingError: boolean): string => { + if (hasWireError) { + return _t('An error occured whilst sharing your live location, please try again'); + } + if (hasStopSharingError) { + return _t('An error occurred while stopping your live location, please try again'); + } + return _t('You are sharing your live location'); +}; + +interface RoomLiveShareWarningInnerProps { + liveBeaconIds: string[]; + roomId: Room['roomId']; +} +const RoomLiveShareWarningInner: React.FC = ({ liveBeaconIds, roomId }) => { const { onStopSharing, + onResetWireError, beacon, stoppingInProgress, - } = useLiveBeacons(roomId); + hasStopSharingError, + hasWireError, + } = useLiveBeacons(liveBeaconIds, roomId); if (!beacon) { return null; } + const hasError = hasStopSharingError || hasWireError; + + const onButtonClick = () => { + if (hasWireError) { + onResetWireError(); + } else { + onStopSharing(); + } + }; + return
- + + - { _t('You are sharing your live location') } + { getLabel(hasWireError, hasStopSharingError) } - { stoppingInProgress ? - : - + { stoppingInProgress && + } + { !stoppingInProgress && !hasError && } + - { _t('Stop sharing') } + { hasError ? _t('Retry') : _t('Stop sharing') } + { hasWireError && + + }
; }; +interface Props { + roomId: Room['roomId']; +} +const RoomLiveShareWarning: React.FC = ({ roomId }) => { + // do we have an active geolocation.watchPosition + const isMonitoringLiveLocation = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.MonitoringLivePosition, + () => OwnBeaconStore.instance.isMonitoringLiveLocation, + ); + + const liveBeaconIds = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.LivenessChange, + () => OwnBeaconStore.instance.getLiveBeaconIds(roomId), + ); + + if (!isMonitoringLiveLocation || !liveBeaconIds.length) { + return null; + } + + // split into outer/inner to avoid watching various parts of live beacon state + // when there are none + return ; +}; + export default RoomLiveShareWarning; diff --git a/src/components/views/beacon/StyledLiveBeaconIcon.tsx b/src/components/views/beacon/StyledLiveBeaconIcon.tsx index 1628b47edc..9c01144671 100644 --- a/src/components/views/beacon/StyledLiveBeaconIcon.tsx +++ b/src/components/views/beacon/StyledLiveBeaconIcon.tsx @@ -19,10 +19,14 @@ import classNames from 'classnames'; import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg'; -const StyledLiveBeaconIcon: React.FC> = ({ className, ...props }) => +interface Props extends React.SVGProps { + // use error styling when true + withError?: boolean; +} +const StyledLiveBeaconIcon: React.FC = ({ className, withError, ...props }) => ; export default StyledLiveBeaconIcon; diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index fd9d42d1a7..c5aff58099 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -219,6 +219,9 @@ export default class TimelineCard extends React.Component { const isUploading = ContentMessages.sharedInstance().getCurrentUploads(this.props.composerRelation).length > 0; + const myMembership = this.props.room.getMyMembership(); + const showComposer = myMembership === "join"; + return ( { ) } - + { showComposer && ( + + ) } ); diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 12a2a12f6e..0218196831 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -430,8 +430,7 @@ const UserOptionsSection: React.FC<{ const roomId = member && member.roomId ? member.roomId : RoomViewStore.instance.getRoomId(); const onInviteUserButton = async (ev: ButtonEvent) => { try { - // We use a MultiInviter to re-use the invite logic, even though - // we're only inviting one user. + // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. const inviter = new MultiInviter(roomId); await inviter.invite([member.userId]).then(() => { if (inviter.getCompletionState(member.userId) !== "invited") { diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 337ff7de25..ce6d1b844e 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -46,6 +46,7 @@ import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; +import { editorRoomKey, editorStateKey } from "../../../Editing"; function getHtmlReplyFallback(mxEvent: MatrixEvent): string { const html = mxEvent.getContent().formatted_body; @@ -222,11 +223,11 @@ class EditMessageComposer extends React.Component { + this.cancelEdit(); + }, }); return; } diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index 600c6c7cb1..e0e1f13f05 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -225,17 +225,6 @@ export default class RoomPreviewBar extends React.Component { .getStateEvents(EventType.RoomJoinRules, "")?.getContent().join_rule; } - private roomName(atStart = false): string { - const name = this.props.room ? this.props.room.name : this.props.roomAlias; - if (name) { - return name; - } else if (atStart) { - return _t("This room"); - } else { - return _t("this room"); - } - } - private getMyMember(): RoomMember { return this.props.room?.getMember(MatrixClientPeg.get().getUserId()); } @@ -287,6 +276,8 @@ export default class RoomPreviewBar extends React.Component { render() { const brand = SdkConfig.get().brand; + const roomName = this.props.room?.name ?? this.props.roomAlias ?? ""; + const isSpace = this.props.room?.isSpaceRoom() ?? this.props.oobData?.roomType === RoomType.Space; let showSpinner = false; let title; @@ -302,7 +293,12 @@ export default class RoomPreviewBar extends React.Component { const messageCase = this.getMessageCase(); switch (messageCase) { case MessageCase.Joining: { - title = this.props.oobData?.roomType === RoomType.Space ? _t("Joining space …") : _t("Joining room …"); + if (this.props.oobData?.roomType || isSpace) { + title = isSpace ? _t("Joining space …") : _t("Joining room …"); + } else { + title = _t("Joining …"); + } + showSpinner = true; break; } @@ -328,7 +324,7 @@ export default class RoomPreviewBar extends React.Component { footer = (
- { _t("Loading room preview") } + { _t("Loading preview") }
); } @@ -336,37 +332,56 @@ export default class RoomPreviewBar extends React.Component { } case MessageCase.Kicked: { const { memberName, reason } = this.getKickOrBanInfo(); - title = _t("You were removed from %(roomName)s by %(memberName)s", - { memberName, roomName: this.roomName() }); + if (roomName) { + title = _t("You were removed from %(roomName)s by %(memberName)s", + { memberName, roomName }); + } else { + title = _t("You were removed by %(memberName)s", { memberName }); + } subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null; - if (this.joinRule() === "invite") { - primaryActionLabel = _t("Forget this room"); - primaryActionHandler = this.props.onForgetClick; + if (isSpace) { + primaryActionLabel = _t("Forget this space"); } else { + primaryActionLabel = _t("Forget this room"); + } + primaryActionHandler = this.props.onForgetClick; + + if (this.joinRule() !== JoinRule.Invite) { + secondaryActionLabel = primaryActionLabel; + secondaryActionHandler = primaryActionHandler; + primaryActionLabel = _t("Re-join"); primaryActionHandler = this.props.onJoinClick; - secondaryActionLabel = _t("Forget this room"); - secondaryActionHandler = this.props.onForgetClick; } break; } case MessageCase.Banned: { const { memberName, reason } = this.getKickOrBanInfo(); - title = _t("You were banned from %(roomName)s by %(memberName)s", - { memberName, roomName: this.roomName() }); + if (roomName) { + title = _t("You were banned from %(roomName)s by %(memberName)s", { memberName, roomName }); + } else { + title = _t("You were banned by %(memberName)s", { memberName }); + } subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null; - primaryActionLabel = _t("Forget this room"); + if (isSpace) { + primaryActionLabel = _t("Forget this space"); + } else { + primaryActionLabel = _t("Forget this room"); + } primaryActionHandler = this.props.onForgetClick; break; } case MessageCase.OtherThreePIDError: { - title = _t("Something went wrong with your invite to %(roomName)s", - { roomName: this.roomName() }); + if (roomName) { + title = _t("Something went wrong with your invite to %(roomName)s", { roomName }); + } else { + title = _t("Something went wrong with your invite."); + } const joinRule = this.joinRule(); const errCodeMessage = _t( "An error (%(errcode)s) was returned while trying to validate your " + - "invite. You could try to pass this information on to a room admin.", + "invite. You could try to pass this information on to the person who invited you.", { errcode: this.state.threePidFetchError.errcode || _t("unknown error code") }, ); switch (joinRule) { @@ -379,7 +394,7 @@ export default class RoomPreviewBar extends React.Component { primaryActionHandler = this.props.onJoinClick; break; case "public": - subTitle = _t("You can still join it because this is a public room."); + subTitle = _t("You can still join here."); primaryActionLabel = _t("Join the discussion"); primaryActionHandler = this.props.onJoinClick; break; @@ -392,14 +407,22 @@ export default class RoomPreviewBar extends React.Component { break; } case MessageCase.InvitedEmailNotFoundInAccount: { - title = _t( - "This invite to %(roomName)s was sent to %(email)s which is not " + - "associated with your account", - { - roomName: this.roomName(), - email: this.props.invitedEmail, - }, - ); + if (roomName) { + title = _t( + "This invite to %(roomName)s was sent to %(email)s which is not " + + "associated with your account", + { + roomName, + email: this.props.invitedEmail, + }, + ); + } else { + title = _t( + "This invite was sent to %(email)s which is not associated with your account", + { email: this.props.invitedEmail }, + ); + } + subTitle = _t( "Link this email with your account in Settings to receive invites " + "directly in %(brand)s.", @@ -410,13 +433,18 @@ export default class RoomPreviewBar extends React.Component { break; } case MessageCase.InvitedEmailNoIdentityServer: { - title = _t( - "This invite to %(roomName)s was sent to %(email)s", - { - roomName: this.roomName(), - email: this.props.invitedEmail, - }, - ); + if (roomName) { + title = _t( + "This invite to %(roomName)s was sent to %(email)s", + { + roomName, + email: this.props.invitedEmail, + }, + ); + } else { + title = _t("This invite was sent to %(email)s", { email: this.props.invitedEmail }); + } + subTitle = _t( "Use an identity server in Settings to receive invites directly in %(brand)s.", { brand }, @@ -426,13 +454,18 @@ export default class RoomPreviewBar extends React.Component { break; } case MessageCase.InvitedEmailMismatch: { - title = _t( - "This invite to %(roomName)s was sent to %(email)s", - { - roomName: this.roomName(), - email: this.props.invitedEmail, - }, - ); + if (roomName) { + title = _t( + "This invite to %(roomName)s was sent to %(email)s", + { + roomName, + email: this.props.invitedEmail, + }, + ); + } else { + title = _t("This invite was sent to %(email)s", { email: this.props.invitedEmail }); + } + subTitle = _t( "Share this email in Settings to receive invites directly in %(brand)s.", { brand }, @@ -458,16 +491,14 @@ export default class RoomPreviewBar extends React.Component { const isDM = this.isDMInvite(); if (isDM) { - title = _t("Do you want to chat with %(user)s?", - { user: inviteMember.name }); + title = _t("Do you want to chat with %(user)s?", { user: inviteMember.name }); subTitle = [ avatar, _t(" wants to chat", {}, { userName: () => inviterElement }), ]; primaryActionLabel = _t("Start chatting"); } else { - title = _t("Do you want to join %(roomName)s?", - { roomName: this.roomName() }); + title = _t("Do you want to join %(roomName)s?", { roomName }); subTitle = [ avatar, _t(" invited you", {}, { userName: () => inviterElement }), @@ -500,27 +531,35 @@ export default class RoomPreviewBar extends React.Component { } case MessageCase.ViewingRoom: { if (this.props.canPreview) { - title = _t("You're previewing %(roomName)s. Want to join it?", - { roomName: this.roomName() }); + title = _t("You're previewing %(roomName)s. Want to join it?", { roomName }); + } else if (roomName) { + title = _t("%(roomName)s can't be previewed. Do you want to join it?", { roomName }); } else { - title = _t("%(roomName)s can't be previewed. Do you want to join it?", - { roomName: this.roomName(true) }); + title = _t("There's no preview, would you like to join?"); } primaryActionLabel = _t("Join the discussion"); primaryActionHandler = this.props.onJoinClick; break; } case MessageCase.RoomNotFound: { - title = _t("%(roomName)s does not exist.", { roomName: this.roomName(true) }); - subTitle = _t("This room doesn't exist. Are you sure you're at the right place?"); + if (roomName) { + title = _t("%(roomName)s does not exist.", { roomName }); + } else { + title = _t("This room or space does not exist."); + } + subTitle = _t("Are you sure you're at the right place?"); break; } case MessageCase.OtherError: { - title = _t("%(roomName)s is not accessible at this time.", { roomName: this.roomName(true) }); + if (roomName) { + title = _t("%(roomName)s is not accessible at this time.", { roomName }); + } else { + title = _t("This room or space is not accessible at this time."); + } subTitle = [ - _t("Try again later, or ask a room admin to check if you have access."), + _t("Try again later, or ask a room or space admin to check if you have access."), _t( - "%(errcode)s was returned while trying to access the room. " + + "%(errcode)s was returned while trying to access the room or space. " + "If you think you're seeing this message in error, please " + "submit a bug report.", { errcode: this.props.error.errcode }, diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 713f46d435..b7967aa5ea 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -59,14 +59,12 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { addReplyToMessageContent } from '../../../utils/Reply'; -export function attachRelation( - content: IContent, - relation?: IEventRelation, -): void { +// Merges favouring the given relation +export function attachRelation(content: IContent, relation?: IEventRelation): void { if (relation) { content['m.relates_to'] = { - ...relation, // the composer can have a default - ...content['m.relates_to'], + ...(content['m.relates_to'] || {}), + ...relation, }; } } @@ -99,6 +97,7 @@ export function createMessageContent( content.formatted_body = formattedBody; } + attachRelation(content, relation); if (replyToEvent) { addReplyToMessageContent(content, replyToEvent, { permalinkCreator, @@ -106,13 +105,6 @@ export function createMessageContent( }); } - if (relation) { - content['m.relates_to'] = { - ...relation, - ...content['m.relates_to'], - }; - } - return content; } diff --git a/src/components/views/rooms/ThreadSummary.tsx b/src/components/views/rooms/ThreadSummary.tsx index 9a13814f89..50a74aa20d 100644 --- a/src/components/views/rooms/ThreadSummary.tsx +++ b/src/components/views/rooms/ThreadSummary.tsx @@ -90,17 +90,16 @@ export const ThreadMessagePreview = ({ thread, showDisplayname = false }: IPrevi }, [lastReply, replacingEventId]); if (!preview) return null; - const sender = thread.roomState.getSentinelMember(lastReply.getSender()); return <> { showDisplayname &&
- { sender?.name ?? lastReply.getSender() } + { lastReply.sender?.name ?? lastReply.getSender() }
}
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index f31fc8f9fc..84898c8d08 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -231,7 +231,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent; + return ; } // only other UI is the recording-in-progress UI diff --git a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx index 15b87b084f..408e92e3a8 100644 --- a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx @@ -97,6 +97,7 @@ export default class AdvancedRoomSettingsTab extends React.Component - { _t("Upgrade this room to the recommended room version") } + { isSpace + ? _t("Upgrade this space to the recommended room version") + : _t("Upgrade this room to the recommended room version") }
); @@ -128,12 +131,16 @@ export default class AdvancedRoomSettingsTab extends React.Component - { _t("View older messages in %(roomName)s.", { roomName: name }) } + { copy } ); } diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts index b3184f63f0..3b5c5e638b 100644 --- a/src/editor/deserialize.ts +++ b/src/editor/deserialize.ts @@ -218,7 +218,7 @@ function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]): return parts; } case "OL": { - let counter = 1; + let counter = (n as HTMLOListElement).start ?? 1; const parts = parseChildren(n, pc, li => { const parts = [pc.plain(`${counter}. `), ...parseChildren(li, pc)]; counter++; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8329ef0ce9..85f230c0fe 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -384,7 +384,7 @@ "Custom (%(level)s)": "Custom (%(level)s)", "Failed to invite": "Failed to invite", "Operation failed": "Operation failed", - "Failed to invite users to the room:": "Failed to invite users to the room:", + "Failed to invite users to %(roomName)s": "Failed to invite users to %(roomName)s", "We sent the others, but the below people couldn't be invited to ": "We sent the others, but the below people couldn't be invited to ", "Some invites couldn't be sent": "Some invites couldn't be sent", "You need to be logged in.": "You need to be logged in.", @@ -694,12 +694,16 @@ "Not a valid %(brand)s keyfile": "Not a valid %(brand)s keyfile", "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?", "Unrecognised address": "Unrecognised address", + "You do not have permission to invite people to this space.": "You do not have permission to invite people to this space.", "You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.", - "User %(userId)s is already invited to the room": "User %(userId)s is already invited to the room", - "User %(userId)s is already in the room": "User %(userId)s is already in the room", - "User %(user_id)s does not exist": "User %(user_id)s does not exist", - "User %(user_id)s may or may not exist": "User %(user_id)s may or may not exist", + "User is already invited to the space": "User is already invited to the space", + "User is already invited to the room": "User is already invited to the room", + "User is already in the space": "User is already in the space", + "User is already in the room": "User is already in the room", + "User does not exist": "User does not exist", + "User may or may not exist": "User may or may not exist", "The user must be unbanned before they can be invited.": "The user must be unbanned before they can be invited.", + "The user's homeserver does not support the version of the space.": "The user's homeserver does not support the version of the space.", "The user's homeserver does not support the version of the room.": "The user's homeserver does not support the version of the room.", "Unknown server error": "Unknown server error", "Use a few words, avoid common phrases": "Use a few words, avoid common phrases", @@ -815,12 +819,12 @@ "Update %(brand)s": "Update %(brand)s", "New version of %(brand)s is available": "New version of %(brand)s is available", "Guest": "Guest", - "There was an error joining the room": "There was an error joining the room", - "Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.", + "There was an error joining.": "There was an error joining.", + "Sorry, your homeserver is too old to participate here.": "Sorry, your homeserver is too old to participate here.", "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", - "The person who invited you already left the room.": "The person who invited you already left the room.", - "The person who invited you already left the room, or their server is offline.": "The person who invited you already left the room, or their server is offline.", - "Failed to join room": "Failed to join room", + "The person who invited you has already left.": "The person who invited you has already left.", + "The person who invited you has already left, or their server is offline.": "The person who invited you has already left, or their server is offline.", + "Failed to join": "Failed to join", "All rooms": "All rooms", "Home": "Home", "Favourites": "Favourites", @@ -889,7 +893,7 @@ "Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)", "Don't send read receipts": "Don't send read receipts", "Location sharing - pin drop (under active development)": "Location sharing - pin drop (under active development)", - "Location sharing - share your current location with live updates (under active development)": "Location sharing - share your current location with live updates (under active development)", + "Live location sharing - share current location (active development, and temporarily, locations persist in room history)": "Live location sharing - share current location (active development, and temporarily, locations persist in room history)", "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", @@ -1518,8 +1522,9 @@ "Voice & Video": "Voice & Video", "This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers", "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.", + "Upgrade this space to the recommended room version": "Upgrade this space to the recommended room version", "Upgrade this room to the recommended room version": "Upgrade this room to the recommended room version", - "this room": "this room", + "View older version of %(spaceName)s.": "View older version of %(spaceName)s.", "View older messages in %(roomName)s.": "View older messages in %(roomName)s.", "Space information": "Space information", "Internal room ID": "Internal room ID", @@ -1779,29 +1784,35 @@ "Currently removing messages in %(count)s rooms|one": "Currently removing messages in %(count)s room", "%(spaceName)s menu": "%(spaceName)s menu", "Home options": "Home options", - "This room": "This room", "Joining space …": "Joining space …", "Joining room …": "Joining room …", + "Joining …": "Joining …", "Loading …": "Loading …", "Rejecting invite …": "Rejecting invite …", "Join the conversation with an account": "Join the conversation with an account", "Sign Up": "Sign Up", - "Loading room preview": "Loading room preview", + "Loading preview": "Loading preview", "You were removed from %(roomName)s by %(memberName)s": "You were removed from %(roomName)s by %(memberName)s", + "You were removed by %(memberName)s": "You were removed by %(memberName)s", "Reason: %(reason)s": "Reason: %(reason)s", + "Forget this space": "Forget this space", "Forget this room": "Forget this room", "Re-join": "Re-join", "You were banned from %(roomName)s by %(memberName)s": "You were banned from %(roomName)s by %(memberName)s", + "You were banned by %(memberName)s": "You were banned by %(memberName)s", "Something went wrong with your invite to %(roomName)s": "Something went wrong with your invite to %(roomName)s", - "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to a room admin.": "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to a room admin.", + "Something went wrong with your invite.": "Something went wrong with your invite.", + "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to the person who invited you.": "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to the person who invited you.", "unknown error code": "unknown error code", "You can only join it with a working invite.": "You can only join it with a working invite.", "Try to join anyway": "Try to join anyway", - "You can still join it because this is a public room.": "You can still join it because this is a public room.", + "You can still join here.": "You can still join here.", "Join the discussion": "Join the discussion", "This invite to %(roomName)s was sent to %(email)s which is not associated with your account": "This invite to %(roomName)s was sent to %(email)s which is not associated with your account", + "This invite was sent to %(email)s which is not associated with your account": "This invite was sent to %(email)s which is not associated with your account", "Link this email with your account in Settings to receive invites directly in %(brand)s.": "Link this email with your account in Settings to receive invites directly in %(brand)s.", "This invite to %(roomName)s was sent to %(email)s": "This invite to %(roomName)s was sent to %(email)s", + "This invite was sent to %(email)s": "This invite was sent to %(email)s", "Use an identity server in Settings to receive invites directly in %(brand)s.": "Use an identity server in Settings to receive invites directly in %(brand)s.", "Share this email in Settings to receive invites directly in %(brand)s.": "Share this email in Settings to receive invites directly in %(brand)s.", "Do you want to chat with %(user)s?": "Do you want to chat with %(user)s?", @@ -1813,11 +1824,14 @@ "Reject & Ignore user": "Reject & Ignore user", "You're previewing %(roomName)s. Want to join it?": "You're previewing %(roomName)s. Want to join it?", "%(roomName)s can't be previewed. Do you want to join it?": "%(roomName)s can't be previewed. Do you want to join it?", + "There's no preview, would you like to join?": "There's no preview, would you like to join?", "%(roomName)s does not exist.": "%(roomName)s does not exist.", - "This room doesn't exist. Are you sure you're at the right place?": "This room doesn't exist. Are you sure you're at the right place?", + "This room or space does not exist.": "This room or space does not exist.", + "Are you sure you're at the right place?": "Are you sure you're at the right place?", "%(roomName)s is not accessible at this time.": "%(roomName)s is not accessible at this time.", - "Try again later, or ask a room admin to check if you have access.": "Try again later, or ask a room admin to check if you have access.", - "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please submit a bug report.": "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please submit a bug report.", + "This room or space is not accessible at this time.": "This room or space is not accessible at this time.", + "Try again later, or ask a room or space admin to check if you have access.": "Try again later, or ask a room or space admin to check if you have access.", + "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.": "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.", "Appearance": "Appearance", "Show rooms with unread messages first": "Show rooms with unread messages first", "Show previews of messages": "Show previews of messages", @@ -2882,9 +2896,13 @@ "Beta": "Beta", "Leave the beta": "Leave the beta", "Join the beta": "Join the beta", + "An error occured whilst sharing your live location": "An error occured whilst sharing your live location", "You are sharing your live location": "You are sharing your live location", "%(timeRemaining)s left": "%(timeRemaining)s left", + "An error occured whilst sharing your live location, please try again": "An error occured whilst sharing your live location, please try again", + "An error occurred while stopping your live location, please try again": "An error occurred while stopping your live location, please try again", "Stop sharing": "Stop sharing", + "Stop sharing and close": "Stop sharing and close", "Avatar": "Avatar", "This room is public": "This room is public", "Away": "Away", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index f8e7bb6a30..452c2185c5 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -400,7 +400,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { isFeature: true, labsGroup: LabGroup.Messaging, supportedLevels: LEVELS_FEATURE, - displayName: _td("Location sharing - share your current location with live updates (under active development)"), + displayName: _td( + `Live location sharing - share current location ` + + `(active development, and temporarily, locations persist in room history)`, + ), default: false, }, "baseFontSize": { diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index 6f7bec93dd..31ddd42b85 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -20,6 +20,9 @@ import { BeaconEvent, MatrixEvent, Room, + RoomMember, + RoomState, + RoomStateEvent, } from "matrix-js-sdk/src/matrix"; import { BeaconInfoState, makeBeaconContent, makeBeaconInfoContent, @@ -35,6 +38,7 @@ import { ClearWatchCallback, GeolocationError, mapGeolocationPositionToTimedGeo, + sortBeaconsByLatestCreation, TimedGeoUri, watchPosition, } from "../utils/beacon"; @@ -45,13 +49,17 @@ const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconIn export enum OwnBeaconStoreEvent { LivenessChange = 'OwnBeaconStore.LivenessChange', MonitoringLivePosition = 'OwnBeaconStore.MonitoringLivePosition', + WireError = 'WireError', } const MOVING_UPDATE_INTERVAL = 2000; const STATIC_UPDATE_INTERVAL = 30000; +const BAIL_AFTER_CONSECUTIVE_ERROR_COUNT = 2; + type OwnBeaconStoreState = { beacons: Map; + beaconWireErrors: Map; beaconsByRoomId: Map>; liveBeaconIds: string[]; }; @@ -60,6 +68,16 @@ export class OwnBeaconStore extends AsyncStoreWithClient { // users beacons, keyed by event type public readonly beacons = new Map(); public readonly beaconsByRoomId = new Map>(); + /** + * Track over the wire errors for published positions + * Counts consecutive wire errors per beacon + * Reset on successful publish of location + */ + public readonly beaconWireErrorCounts = new Map(); + /** + * ids of live beacons + * ordered by creation time descending + */ private liveBeaconIds = []; private locationInterval: number; private geolocationError: GeolocationError | undefined; @@ -90,6 +108,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { protected async onNotReady() { this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness); this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon); + this.matrixClient.removeListener(RoomStateEvent.Members, this.onRoomStateMembers); this.beacons.forEach(beacon => beacon.destroy()); @@ -97,11 +116,13 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.beacons.clear(); this.beaconsByRoomId.clear(); this.liveBeaconIds = []; + this.beaconWireErrorCounts.clear(); } protected async onReady(): Promise { this.matrixClient.on(BeaconEvent.LivenessChange, this.onBeaconLiveness); this.matrixClient.on(BeaconEvent.New, this.onNewBeacon); + this.matrixClient.on(RoomStateEvent.Members, this.onRoomStateMembers); this.initialiseBeaconState(); } @@ -110,20 +131,51 @@ export class OwnBeaconStore extends AsyncStoreWithClient { // we don't actually do anything here } - public hasLiveBeacons(roomId?: string): boolean { + public hasLiveBeacons = (roomId?: string): boolean => { return !!this.getLiveBeaconIds(roomId).length; - } + }; - public getLiveBeaconIds(roomId?: string): string[] { + /** + * Some live beacon has a wire error + * Optionally filter by room + */ + public hasWireErrors = (roomId?: string): boolean => { + return this.getLiveBeaconIds(roomId).some(this.beaconHasWireError); + }; + + /** + * If a beacon has failed to publish position + * past the allowed consecutive failure count (BAIL_AFTER_CONSECUTIVE_ERROR_COUNT) + * Then consider it to have an error + */ + public beaconHasWireError = (beaconId: string): boolean => { + return this.beaconWireErrorCounts.get(beaconId) >= BAIL_AFTER_CONSECUTIVE_ERROR_COUNT; + }; + + public resetWireError = (beaconId: string): void => { + this.incrementBeaconWireErrorCount(beaconId, false); + + // always publish to all live beacons together + // instead of just one that was changed + // to keep lastPublishedTimestamp simple + // and extra published locations don't hurt + this.publishCurrentLocationToBeacons(); + }; + + public getLiveBeaconIds = (roomId?: string): string[] => { if (!roomId) { return this.liveBeaconIds; } return this.liveBeaconIds.filter(beaconId => this.beaconsByRoomId.get(roomId)?.has(beaconId)); - } + }; - public getBeaconById(beaconId: string): Beacon | undefined { + public getLiveBeaconIdsWithWireError = (roomId?: string): string[] => { + return this.getLiveBeaconIds(roomId).filter(this.beaconHasWireError); + }; + + public getBeaconById = (beaconId: string): Beacon | undefined => { return this.beacons.get(beaconId); - } + }; public stopBeacon = async (beaconInfoType: string): Promise => { const beacon = this.beacons.get(beaconInfoType); @@ -136,6 +188,10 @@ export class OwnBeaconStore extends AsyncStoreWithClient { return await this.updateBeaconEvent(beacon, { live: false }); }; + /** + * Listeners + */ + private onNewBeacon = (_event: MatrixEvent, beacon: Beacon): void => { if (!isOwnBeacon(beacon, this.matrixClient.getUserId())) { return; @@ -160,6 +216,40 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.emit(OwnBeaconStoreEvent.LivenessChange, this.getLiveBeaconIds()); }; + /** + * Check for changes in membership in rooms with beacons + * and stop monitoring beacons in rooms user is no longer member of + */ + private onRoomStateMembers = (_event: MatrixEvent, roomState: RoomState, member: RoomMember): void => { + // no beacons for this room, ignore + if ( + !this.beaconsByRoomId.has(roomState.roomId) || + member.userId !== this.matrixClient.getUserId() + ) { + return; + } + + // TODO check powerlevels here + // in PSF-797 + + // stop watching beacons in rooms where user is no longer a member + if (member.membership === 'leave' || member.membership === 'ban') { + this.beaconsByRoomId.get(roomState.roomId).forEach(this.removeBeacon); + this.beaconsByRoomId.delete(roomState.roomId); + } + }; + + /** + * State management + */ + + /** + * Live beacon ids that do not have wire errors + */ + private get healthyLiveBeaconIds() { + return this.liveBeaconIds.filter(beaconId => !this.beaconHasWireError(beaconId)); + } + private initialiseBeaconState = () => { const userId = this.matrixClient.getUserId(); const visibleRooms = this.matrixClient.getVisibleRooms(); @@ -187,10 +277,26 @@ export class OwnBeaconStore extends AsyncStoreWithClient { beacon.monitorLiveness(); }; + /** + * Remove listeners for a given beacon + * remove from state + * and update liveness if changed + */ + private removeBeacon = (beaconId: string): void => { + if (!this.beacons.has(beaconId)) { + return; + } + this.beacons.get(beaconId).destroy(); + this.beacons.delete(beaconId); + + this.checkLiveness(); + }; + private checkLiveness = (): void => { const prevLiveBeaconIds = this.getLiveBeaconIds(); this.liveBeaconIds = [...this.beacons.values()] .filter(beacon => beacon.isLive) + .sort(sortBeaconsByLatestCreation) .map(beacon => beacon.identifier); const diff = arrayDiff(prevLiveBeaconIds, this.liveBeaconIds); @@ -218,20 +324,9 @@ export class OwnBeaconStore extends AsyncStoreWithClient { } }; - private updateBeaconEvent = async (beacon: Beacon, update: Partial): Promise => { - const { description, timeout, timestamp, live, assetType } = { - ...beacon.beaconInfo, - ...update, - }; - - const updateContent = makeBeaconInfoContent(timeout, - live, - description, - assetType, - timestamp); - - await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent); - }; + /** + * Geolocation + */ private togglePollingLocation = () => { if (!!this.liveBeaconIds.length) { @@ -270,17 +365,6 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.emit(OwnBeaconStoreEvent.MonitoringLivePosition); }; - private onWatchedPosition = (position: GeolocationPosition) => { - const timedGeoPosition = mapGeolocationPositionToTimedGeo(position); - - // if this is our first position, publish immediateley - if (!this.lastPublishedPositionTimestamp) { - this.publishLocationToBeacons(timedGeoPosition); - } else { - this.debouncedPublishLocationToBeacons(timedGeoPosition); - } - }; - private stopPollingLocation = () => { clearInterval(this.locationInterval); this.locationInterval = undefined; @@ -295,40 +379,14 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.emit(OwnBeaconStoreEvent.MonitoringLivePosition); }; - /** - * Sends m.location events to all live beacons - * Sets last published beacon - */ - private publishLocationToBeacons = async (position: TimedGeoUri) => { - this.lastPublishedPositionTimestamp = Date.now(); - // TODO handle failure in individual beacon without rejecting rest - await Promise.all(this.liveBeaconIds.map(beaconId => - this.sendLocationToBeacon(this.beacons.get(beaconId), position)), - ); - }; + private onWatchedPosition = (position: GeolocationPosition) => { + const timedGeoPosition = mapGeolocationPositionToTimedGeo(position); - private debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL); - - /** - * Sends m.location event to referencing given beacon - */ - private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri) => { - const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId); - await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content); - }; - - /** - * Gets the current location - * (as opposed to using watched location) - * and publishes it to all live beacons - */ - private publishCurrentLocationToBeacons = async () => { - try { - const position = await getCurrentPosition(); - // TODO error handling - this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position)); - } catch (error) { - this.onGeolocationError(error?.message); + // if this is our first position, publish immediateley + if (!this.lastPublishedPositionTimestamp) { + this.publishLocationToBeacons(timedGeoPosition); + } else { + this.debouncedPublishLocationToBeacons(timedGeoPosition); } }; @@ -350,4 +408,89 @@ export class OwnBeaconStore extends AsyncStoreWithClient { // TODO may need adjustment when PSF-797 is done await Promise.all(this.liveBeaconIds.map(this.stopBeacon)); }; + + /** + * Gets the current location + * (as opposed to using watched location) + * and publishes it to all live beacons + */ + private publishCurrentLocationToBeacons = async () => { + try { + const position = await getCurrentPosition(); + this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position)); + } catch (error) { + this.onGeolocationError(error?.message); + } + }; + + /** + * MatrixClient api + */ + + private updateBeaconEvent = async (beacon: Beacon, update: Partial): Promise => { + const { description, timeout, timestamp, live, assetType } = { + ...beacon.beaconInfo, + ...update, + }; + + const updateContent = makeBeaconInfoContent(timeout, + live, + description, + assetType, + timestamp); + + await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent); + }; + + /** + * Sends m.location events to all live beacons + * Sets last published beacon + */ + private publishLocationToBeacons = async (position: TimedGeoUri) => { + this.lastPublishedPositionTimestamp = Date.now(); + await Promise.all(this.healthyLiveBeaconIds.map(beaconId => + this.sendLocationToBeacon(this.beacons.get(beaconId), position)), + ); + }; + + private debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL); + + /** + * Sends m.location event to referencing given beacon + */ + private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri) => { + const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId); + try { + await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content); + this.incrementBeaconWireErrorCount(beacon.identifier, false); + } catch (error) { + logger.error(error); + this.incrementBeaconWireErrorCount(beacon.identifier, true); + } + }; + + /** + * Manage beacon wire error count + * - clear count for beacon when not error + * - increment count for beacon when is error + * - emit if beacon error count crossed threshold + */ + private incrementBeaconWireErrorCount = (beaconId: string, isError: boolean): void => { + const hadError = this.beaconHasWireError(beaconId); + + if (isError) { + // increment error count + this.beaconWireErrorCounts.set( + beaconId, + (this.beaconWireErrorCounts.get(beaconId) ?? 0) + 1, + ); + } else { + // clear any error count + this.beaconWireErrorCounts.delete(beaconId); + } + + if (this.beaconHasWireError(beaconId) !== hadError) { + this.emit(OwnBeaconStoreEvent.WireError, beaconId); + } + }; } diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index d802845eee..b48ad3f808 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -428,14 +428,14 @@ export class RoomViewStore extends Store { } public showJoinRoomError(err: MatrixError, roomId: string) { - let msg: ReactNode = err.message ? err.message : JSON.stringify(err); - logger.log("Failed to join room:", msg); + let description: ReactNode = err.message ? err.message : JSON.stringify(err); + logger.log("Failed to join room:", description); if (err.name === "ConnectionError") { - msg = _t("There was an error joining the room"); + description = _t("There was an error joining."); } else if (err.errcode === 'M_INCOMPATIBLE_ROOM_VERSION') { - msg =
- { _t("Sorry, your homeserver is too old to participate in this room.") }
+ description =
+ { _t("Sorry, your homeserver is too old to participate here.") }
{ _t("Please contact your homeserver administrator.") }
; } else if (err.httpStatus === 404) { @@ -444,16 +444,16 @@ export class RoomViewStore extends Store { if (invitingUserId) { // if the inviting user is on the same HS, there can only be one cause: they left. if (invitingUserId.endsWith(`:${this.matrixClient.getDomain()}`)) { - msg = _t("The person who invited you already left the room."); + description = _t("The person who invited you has already left."); } else { - msg = _t("The person who invited you already left the room, or their server is offline."); + description = _t("The person who invited you has already left, or their server is offline."); } } } Modal.createTrackedDialog('Failed to join room', '', ErrorDialog, { - title: _t("Failed to join room"), - description: msg, + title: _t("Failed to join"), + description, }); } diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index 517a23fa97..c4c803483d 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -25,17 +25,21 @@ import { EffectiveMembership, getEffectiveMembership } from "../../utils/members import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import * as RoomNotifs from '../../RoomNotifs'; import * as Unread from '../../Unread'; -import { NotificationState } from "./NotificationState"; +import { NotificationState, NotificationStateEvents } from "./NotificationState"; import { getUnsentMessages } from "../../components/structures/RoomStatusBar"; +import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState"; export class RoomNotificationState extends NotificationState implements IDestroyable { - constructor(public readonly room: Room) { + constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) { super(); this.room.on(RoomEvent.Receipt, this.handleReadReceipt); this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate); this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate); this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); + if (threadsState) { + threadsState.on(NotificationStateEvents.Update, this.handleThreadsUpdate); + } MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); this.updateNotificationState(); @@ -52,12 +56,19 @@ export class RoomNotificationState extends NotificationState implements IDestroy this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate); this.room.removeListener(RoomEvent.MyMembership, this.handleMembershipUpdate); this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); + if (this.threadsState) { + this.threadsState.removeListener(NotificationStateEvents.Update, this.handleThreadsUpdate); + } if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); MatrixClientPeg.get().removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate); } } + private handleThreadsUpdate = () => { + this.updateNotificationState(); + }; + private handleLocalEchoUpdated = () => { this.updateNotificationState(); }; diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index 6090797384..887e1a7332 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -82,12 +82,13 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { */ public getRoomState(room: Room): RoomNotificationState { if (!this.roomMap.has(room)) { - this.roomMap.set(room, new RoomNotificationState(room)); // Not very elegant, but that way we ensure that we start tracking // threads notification at the same time at rooms. // There are multiple entry points, and it's unclear which one gets // called first - this.roomThreadsMap.set(room, new ThreadsRoomNotificationState(room)); + const threadState = new ThreadsRoomNotificationState(room); + this.roomThreadsMap.set(room, threadState); + this.roomMap.set(room, new RoomNotificationState(room, threadState)); } return this.roomMap.get(room); } diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index c40e8aef4a..1d505b9b22 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -253,6 +253,9 @@ export default class RightPanelStore extends ReadyWatchingStore { private filterValidCards(rightPanelForRoom?: IRightPanelForRoom) { if (!rightPanelForRoom?.history) return; rightPanelForRoom.history = rightPanelForRoom.history.filter((card) => this.isCardStateValid(card)); + if (!rightPanelForRoom.history.length) { + rightPanelForRoom.isOpen = false; + } } private isCardStateValid(card: IRightPanelCard) { @@ -263,7 +266,11 @@ export default class RightPanelStore extends ReadyWatchingStore { // or potentially other errors. // (A nicer fix could be to indicate, that the right panel is loading if there is missing state data and re-emit if the data is available) switch (card.phase) { + case RightPanelPhases.ThreadPanel: + if (!SettingsStore.getValue("feature_thread")) return false; + break; case RightPanelPhases.ThreadView: + if (!SettingsStore.getValue("feature_thread")) return false; if (!card.state.threadHeadEvent) { console.warn("removed card from right panel because of missing threadHeadEvent in card state"); } diff --git a/src/utils/Image.ts b/src/utils/Image.ts index 57ed3eefa6..66d43b1ca4 100644 --- a/src/utils/Image.ts +++ b/src/utils/Image.ts @@ -17,7 +17,8 @@ import { arrayHasDiff } from "./arrays"; export function mayBeAnimated(mimeType: string): boolean { - return ["image/gif", "image/webp", "image/png", "image/apng"].includes(mimeType); + // AVIF animation support at the time of writing is only available in Chrome hence not having `blobIsAnimated` check + return ["image/gif", "image/webp", "image/png", "image/apng", "image/avif"].includes(mimeType); } function arrayBufferRead(arr: ArrayBuffer, start: number, len: number): Uint8Array { diff --git a/src/utils/MultiInviter.ts b/src/utils/MultiInviter.ts index d3083f2c41..9916916f8c 100644 --- a/src/utils/MultiInviter.ts +++ b/src/utils/MultiInviter.ts @@ -203,18 +203,32 @@ export default class MultiInviter { logger.error(err); - let errorText; + const isSpace = this.roomId && this.matrixClient.getRoom(this.roomId)?.isSpaceRoom(); + + let errorText: string; let fatal = false; switch (err.errcode) { case "M_FORBIDDEN": - errorText = _t('You do not have permission to invite people to this room.'); + if (isSpace) { + errorText = _t('You do not have permission to invite people to this space.'); + } else { + errorText = _t('You do not have permission to invite people to this room.'); + } fatal = true; break; case USER_ALREADY_INVITED: - errorText = _t("User %(userId)s is already invited to the room", { userId: address }); + if (isSpace) { + errorText = _t("User is already invited to the space"); + } else { + errorText = _t("User is already invited to the room"); + } break; case USER_ALREADY_JOINED: - errorText = _t("User %(userId)s is already in the room", { userId: address }); + if (isSpace) { + errorText = _t("User is already in the space"); + } else { + errorText = _t("User is already in the room"); + } break; case "M_LIMIT_EXCEEDED": // we're being throttled so wait a bit & try again @@ -224,10 +238,10 @@ export default class MultiInviter { return; case "M_NOT_FOUND": case "M_USER_NOT_FOUND": - errorText = _t("User %(user_id)s does not exist", { user_id: address }); + errorText = _t("User does not exist"); break; case "M_PROFILE_UNDISCLOSED": - errorText = _t("User %(user_id)s may or may not exist", { user_id: address }); + errorText = _t("User may or may not exist"); break; case "M_PROFILE_NOT_FOUND": if (!ignoreProfile) { @@ -241,7 +255,11 @@ export default class MultiInviter { errorText = _t("The user must be unbanned before they can be invited."); break; case "M_UNSUPPORTED_ROOM_VERSION": - errorText = _t("The user's homeserver does not support the version of the room."); + if (isSpace) { + errorText = _t("The user's homeserver does not support the version of the space."); + } else { + errorText = _t("The user's homeserver does not support the version of the room."); + } break; } diff --git a/src/utils/Reply.ts b/src/utils/Reply.ts index 43144a34a1..87cec55373 100644 --- a/src/utils/Reply.ts +++ b/src/utils/Reply.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { IContent, IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; import sanitizeHtml from "sanitize-html"; import escapeHtml from "escape-html"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; @@ -22,7 +22,6 @@ import { MsgType } from "matrix-js-sdk/src/@types/event"; import { PERMITTED_URL_SCHEMES } from "../HtmlUtils"; import { makeUserPermalink, RoomPermalinkCreator } from "./permalinks/Permalinks"; -import { RecursivePartial } from "../@types/common"; import SettingsStore from "../settings/SettingsStore"; export function getParentEventId(ev?: MatrixEvent): string | undefined { @@ -144,30 +143,17 @@ export function getNestedReplyText( return { body, html }; } -export function makeReplyMixIn(ev?: MatrixEvent): RecursivePartial { +export function makeReplyMixIn(ev?: MatrixEvent): IEventRelation { if (!ev) return {}; - const mixin: RecursivePartial = { - 'm.relates_to': { - 'm.in_reply_to': { - 'event_id': ev.getId(), - }, + const mixin: IEventRelation = { + 'm.in_reply_to': { + 'event_id': ev.getId(), }, }; - /** - * If the event replied is part of a thread - * Add the `m.thread` relation so that clients - * that know how to handle that relation will - * be able to render them more accurately - */ - if (ev.isThreadRelation || ev.isThreadRoot) { - mixin['m.relates_to'] = { - ...mixin['m.relates_to'], - is_falling_back: false, - rel_type: THREAD_RELATION_TYPE.name, - event_id: ev.threadRootId, - }; + if (SettingsStore.getValue("feature_thread") && ev.threadRootId) { + mixin.is_falling_back = false; } return mixin; @@ -206,12 +192,13 @@ export function addReplyToMessageContent( includeLegacyFallback: true, }, ): void { - const replyContent = makeReplyMixIn(replyToEvent); - Object.assign(content, replyContent); + content["m.relates_to"] = { + ...(content["m.relates_to"] || {}), + ...makeReplyMixIn(replyToEvent), + }; if (opts.includeLegacyFallback) { - // Part of Replies fallback support - prepend the text we're sending - // with the text we're replying to + // Part of Replies fallback support - prepend the text we're sending with the text we're replying to const nestedReply = getNestedReplyText(replyToEvent, opts.permalinkCreator); if (nestedReply) { if (content.formatted_body) { diff --git a/src/utils/beacon/duration.ts b/src/utils/beacon/duration.ts index 30d5eac485..b8338a8536 100644 --- a/src/utils/beacon/duration.ts +++ b/src/utils/beacon/duration.ts @@ -34,3 +34,7 @@ export const getBeaconExpiryTimestamp = (beacon: Beacon): number => export const sortBeaconsByLatestExpiry = (left: Beacon, right: Beacon): number => getBeaconExpiryTimestamp(right) - getBeaconExpiryTimestamp(left); + +// aka sort by timestamp descending +export const sortBeaconsByLatestCreation = (left: Beacon, right: Beacon): number => + right.beaconInfo.timestamp - left.beaconInfo.timestamp; diff --git a/src/utils/blobs.ts b/src/utils/blobs.ts index 892920d51f..9dea3d226c 100644 --- a/src/utils/blobs.ts +++ b/src/utils/blobs.ts @@ -54,6 +54,7 @@ const ALLOWED_BLOB_MIMETYPES = [ 'image/png', 'image/apng', 'image/webp', + 'image/avif', 'video/mp4', 'video/webm', diff --git a/test/components/views/audio_messages/RecordingPlayback-test.tsx b/test/components/views/audio_messages/RecordingPlayback-test.tsx index 6a75939bcd..931dca34d6 100644 --- a/test/components/views/audio_messages/RecordingPlayback-test.tsx +++ b/test/components/views/audio_messages/RecordingPlayback-test.tsx @@ -26,6 +26,7 @@ import RoomContext, { TimelineRenderingType } from '../../../../src/contexts/Roo import { createAudioContext } from '../../../../src/audio/compat'; import { findByTestId, flushPromises } from '../../../test-utils'; import PlaybackWaveform from '../../../../src/components/views/audio_messages/PlaybackWaveform'; +import SeekBar from "../../../../src/components/views/audio_messages/SeekBar"; jest.mock('../../../../src/audio/compat', () => ({ createAudioContext: jest.fn(), @@ -55,7 +56,7 @@ describe('', () => { const mockChannelData = new Float32Array(); const defaultRoom = { roomId: '!room:server.org', timelineRenderingType: TimelineRenderingType.File }; - const getComponent = (props: { playback: Playback }, room = defaultRoom) => + const getComponent = (props: React.ComponentProps, room = defaultRoom) => mount(, { wrappingComponent: RoomContext.Provider, wrappingComponentProps: { value: room }, @@ -127,34 +128,19 @@ describe('', () => { expect(playback.toggle).toHaveBeenCalled(); }); - it.each([ - [TimelineRenderingType.Notification], - [TimelineRenderingType.File], - [TimelineRenderingType.Pinned], - ])('does not render waveform when timeline rendering type for room is %s', (timelineRenderingType) => { + it('should render a seek bar by default', () => { const playback = new Playback(new ArrayBuffer(8)); - const room = { - ...defaultRoom, - timelineRenderingType, - }; - const component = getComponent({ playback }, room); + const component = getComponent({ playback }); expect(component.find(PlaybackWaveform).length).toBeFalsy(); + expect(component.find(SeekBar).length).toBeTruthy(); }); - it.each([ - [TimelineRenderingType.Room], - [TimelineRenderingType.Thread], - [TimelineRenderingType.ThreadsList], - [TimelineRenderingType.Search], - ])('renders waveform when timeline rendering type for room is %s', (timelineRenderingType) => { + it('should render a waveform when requested', () => { const playback = new Playback(new ArrayBuffer(8)); - const room = { - ...defaultRoom, - timelineRenderingType, - }; - const component = getComponent({ playback }, room); + const component = getComponent({ playback, withWaveform: true }); expect(component.find(PlaybackWaveform).length).toBeTruthy(); + expect(component.find(SeekBar).length).toBeFalsy(); }); }); diff --git a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx index 680e61b89f..a53937a6e5 100644 --- a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx +++ b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx @@ -17,16 +17,22 @@ limitations under the License. import React from 'react'; import { mocked } from 'jest-mock'; import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { Beacon } from 'matrix-js-sdk/src/matrix'; import LeftPanelLiveShareWarning from '../../../../src/components/views/beacon/LeftPanelLiveShareWarning'; import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../../src/stores/OwnBeaconStore'; -import { flushPromises } from '../../../test-utils'; +import { flushPromises, makeBeaconInfoEvent } from '../../../test-utils'; +import dispatcher from '../../../../src/dispatcher/dispatcher'; +import { Action } from '../../../../src/dispatcher/actions'; jest.mock('../../../../src/stores/OwnBeaconStore', () => { // eslint-disable-next-line @typescript-eslint/no-var-requires const EventEmitter = require("events"); class MockOwnBeaconStore extends EventEmitter { - public hasLiveBeacons = jest.fn().mockReturnValue(false); + public getLiveBeaconIdsWithWireError = jest.fn().mockReturnValue([]); + public getBeaconById = jest.fn(); + public getLiveBeaconIds = jest.fn().mockReturnValue([]); } return { // @ts-ignore @@ -43,32 +49,136 @@ describe('', () => { const getComponent = (props = {}) => mount(); + const roomId1 = '!room1:server'; + const roomId2 = '!room2:server'; + const aliceId = '@alive:server'; + + const now = 1647270879403; + const HOUR_MS = 3600000; + + beforeEach(() => { + jest.spyOn(global.Date, 'now').mockReturnValue(now); + jest.spyOn(dispatcher, 'dispatch').mockClear().mockImplementation(() => { }); + }); + + afterAll(() => { + jest.spyOn(global.Date, 'now').mockRestore(); + + jest.restoreAllMocks(); + }); + // 12h old, 12h left + const beacon1 = new Beacon(makeBeaconInfoEvent(aliceId, + roomId1, + { timeout: HOUR_MS * 24, timestamp: now - 12 * HOUR_MS }, + '$1', + )); + // 10h left + const beacon2 = new Beacon(makeBeaconInfoEvent(aliceId, + roomId2, + { timeout: HOUR_MS * 10, timestamp: now }, + '$2', + )); + it('renders nothing when user has no live beacons', () => { const component = getComponent(); expect(component.html()).toBe(null); }); describe('when user has live location monitor', () => { + beforeAll(() => { + mocked(OwnBeaconStore.instance).getBeaconById.mockImplementation(beaconId => { + if (beaconId === beacon1.identifier) { + return beacon1; + } + return beacon2; + }); + }); + beforeEach(() => { mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = true; + mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([]); + mocked(OwnBeaconStore.instance).getLiveBeaconIds.mockReturnValue([beacon2.identifier, beacon1.identifier]); }); + it('renders correctly when not minimized', () => { const component = getComponent(); expect(component).toMatchSnapshot(); }); + it('goes to room of latest beacon when clicked', () => { + const component = getComponent(); + const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); + + act(() => { + component.simulate('click'); + }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + metricsTrigger: undefined, + // latest beacon's room + room_id: roomId2, + }); + }); + it('renders correctly when minimized', () => { const component = getComponent({ isMinimized: true }); expect(component).toMatchSnapshot(); }); + it('renders wire error', () => { + mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([beacon1.identifier]); + const component = getComponent(); + expect(component).toMatchSnapshot(); + }); + + it('goes to room of latest beacon with wire error when clicked', () => { + mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([beacon1.identifier]); + const component = getComponent(); + const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); + + act(() => { + component.simulate('click'); + }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + metricsTrigger: undefined, + // error beacon's room + room_id: roomId1, + }); + }); + + it('goes back to default style when wire errors are cleared', () => { + mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([beacon1.identifier]); + const component = getComponent(); + // error mode + expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual( + 'An error occured whilst sharing your live location', + ); + + act(() => { + mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([]); + OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.WireError, 'abc'); + }); + + component.setProps({}); + + // default mode + expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual( + 'You are sharing your live location', + ); + }); + it('removes itself when user stops having live beacons', async () => { const component = getComponent({ isMinimized: true }); // started out rendered expect(component.html()).toBeTruthy(); - mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false; - OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition); + act(() => { + mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false; + OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition); + }); await flushPromises(); component.setProps({}); diff --git a/test/components/views/beacon/RoomLiveShareWarning-test.tsx b/test/components/views/beacon/RoomLiveShareWarning-test.tsx index 062ea04feb..03f9784738 100644 --- a/test/components/views/beacon/RoomLiveShareWarning-test.tsx +++ b/test/components/views/beacon/RoomLiveShareWarning-test.tsx @@ -25,6 +25,7 @@ import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../../src/stores/OwnB import { advanceDateAndTime, findByTestId, + flushPromisesWithFakeTimers, getMockClientWithEventEmitter, makeBeaconInfoEvent, mockGeolocation, @@ -95,10 +96,11 @@ describe('', () => { beforeEach(() => { mockGeolocation(); jest.spyOn(global.Date, 'now').mockReturnValue(now); - mockClient.unstable_setLiveBeacon.mockClear(); + mockClient.unstable_setLiveBeacon.mockReset().mockResolvedValue({ event_id: '1' }); }); afterEach(async () => { + jest.spyOn(OwnBeaconStore.instance, 'hasWireErrors').mockRestore(); await resetAsyncStoreWithClient(OwnBeaconStore.instance); }); @@ -236,13 +238,37 @@ describe('', () => { const component = getComponent({ roomId: room2Id }); act(() => { - findByTestId(component, 'room-live-share-stop-sharing').at(0).simulate('click'); + findByTestId(component, 'room-live-share-primary-button').at(0).simulate('click'); component.setProps({}); }); expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledTimes(2); expect(component.find('Spinner').length).toBeTruthy(); - expect(findByTestId(component, 'room-live-share-stop-sharing').at(0).props().disabled).toBeTruthy(); + expect(findByTestId(component, 'room-live-share-primary-button').at(0).props().disabled).toBeTruthy(); + }); + + it('displays error when stop sharing fails', async () => { + const component = getComponent({ roomId: room1Id }); + + // fail first time + mockClient.unstable_setLiveBeacon + .mockRejectedValueOnce(new Error('oups')) + .mockResolvedValue(({ event_id: '1' })); + + await act(async () => { + findByTestId(component, 'room-live-share-primary-button').at(0).simulate('click'); + await flushPromisesWithFakeTimers(); + }); + component.setProps({}); + + expect(component.html()).toMatchSnapshot(); + + act(() => { + findByTestId(component, 'room-live-share-primary-button').at(0).simulate('click'); + component.setProps({}); + }); + + expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledTimes(2); }); it('displays again with correct state after stopping a beacon', () => { @@ -251,7 +277,7 @@ describe('', () => { // stop the beacon act(() => { - findByTestId(component, 'room-live-share-stop-sharing').at(0).simulate('click'); + findByTestId(component, 'room-live-share-primary-button').at(0).simulate('click'); }); // time travel until room1Beacon1 is expired act(() => { @@ -267,9 +293,83 @@ describe('', () => { }); // button not disabled and expiry time shown - expect(findByTestId(component, 'room-live-share-stop-sharing').at(0).props().disabled).toBeFalsy(); + expect(findByTestId(component, 'room-live-share-primary-button').at(0).props().disabled).toBeFalsy(); expect(findByTestId(component, 'room-live-share-expiry').text()).toEqual('1h left'); }); }); + + describe('with wire errors', () => { + it('displays wire error when mounted with wire errors', async () => { + const hasWireErrorsSpy = jest.spyOn(OwnBeaconStore.instance, 'hasWireErrors').mockReturnValue(true); + const component = getComponent({ roomId: room2Id }); + + expect(component).toMatchSnapshot(); + expect(hasWireErrorsSpy).toHaveBeenCalledWith(room2Id); + }); + + it('displays wire error when wireError event is emitted and beacons have errors', async () => { + const hasWireErrorsSpy = jest.spyOn(OwnBeaconStore.instance, 'hasWireErrors').mockReturnValue(false); + const component = getComponent({ roomId: room2Id }); + + // update mock and emit event + act(() => { + hasWireErrorsSpy.mockReturnValue(true); + OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.WireError, room2Beacon1.getType()); + }); + component.setProps({}); + + // renders wire error ui + expect(component.find('.mx_RoomLiveShareWarning_label').text()).toEqual( + 'An error occured whilst sharing your live location, please try again', + ); + expect(findByTestId(component, 'room-live-share-wire-error-close-button').length).toBeTruthy(); + }); + + it('stops displaying wire error when errors are cleared', async () => { + const hasWireErrorsSpy = jest.spyOn(OwnBeaconStore.instance, 'hasWireErrors').mockReturnValue(true); + const component = getComponent({ roomId: room2Id }); + + // update mock and emit event + act(() => { + hasWireErrorsSpy.mockReturnValue(false); + OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.WireError, room2Beacon1.getType()); + }); + component.setProps({}); + + // renders error-free ui + expect(component.find('.mx_RoomLiveShareWarning_label').text()).toEqual( + 'You are sharing your live location', + ); + expect(findByTestId(component, 'room-live-share-wire-error-close-button').length).toBeFalsy(); + }); + + it('clicking retry button resets wire errors', async () => { + jest.spyOn(OwnBeaconStore.instance, 'hasWireErrors').mockReturnValue(true); + const resetErrorSpy = jest.spyOn(OwnBeaconStore.instance, 'resetWireError'); + + const component = getComponent({ roomId: room2Id }); + + act(() => { + findByTestId(component, 'room-live-share-primary-button').at(0).simulate('click'); + }); + + expect(resetErrorSpy).toHaveBeenCalledWith(room2Beacon1.getType()); + expect(resetErrorSpy).toHaveBeenCalledWith(room2Beacon2.getType()); + }); + + it('clicking close button stops beacons', async () => { + jest.spyOn(OwnBeaconStore.instance, 'hasWireErrors').mockReturnValue(true); + const stopBeaconSpy = jest.spyOn(OwnBeaconStore.instance, 'stopBeacon'); + + const component = getComponent({ roomId: room2Id }); + + act(() => { + findByTestId(component, 'room-live-share-wire-error-close-button').at(0).simulate('click'); + }); + + expect(stopBeaconSpy).toHaveBeenCalledWith(room2Beacon1.getType()); + expect(stopBeaconSpy).toHaveBeenCalledWith(room2Beacon2.getType()); + }); + }); }); }); diff --git a/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap b/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap index 39c8cc6b6a..bd9f943b35 100644 --- a/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap @@ -4,23 +4,73 @@ exports[` when user has live location monitor rende -
-
+ className="mx_AccessibleButton mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__minimized" + onClick={[Function]} + onKeyDown={[Function]} + onKeyUp={[Function]} + role="button" + tabIndex={0} + title="You are sharing your live location" + > +
+
+ `; exports[` when user has live location monitor renders correctly when not minimized 1`] = ` -
- You are sharing your live location -
+
+ You are sharing your live location +
+ +
+`; + +exports[` when user has live location monitor renders wire error 1`] = ` + + +
+ An error occured whilst sharing your live location +
+
`; diff --git a/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap index 786827fdea..8ae076a2a1 100644 --- a/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap @@ -1,5 +1,86 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` when user has live beacons and geolocation is available renders correctly with one live beacon in room 1`] = `"
You are sharing your live location1h left
"`; +exports[` when user has live beacons and geolocation is available renders correctly with one live beacon in room 1`] = `"
You are sharing your live location1h left
"`; -exports[` when user has live beacons and geolocation is available renders correctly with two live beacons in room 1`] = `"
You are sharing your live location12h left
"`; +exports[` when user has live beacons and geolocation is available renders correctly with two live beacons in room 1`] = `"
You are sharing your live location12h left
"`; + +exports[` when user has live beacons and geolocation is available stopping beacons displays error when stop sharing fails 1`] = `"
An error occurred while stopping your live location, please try again
"`; + +exports[` when user has live beacons and geolocation is available with wire errors displays wire error when mounted with wire errors 1`] = ` + + +
+ +
+ + + An error occured whilst sharing your live location, please try again + + + + + + + +
+ + +`; diff --git a/test/components/views/rooms/RoomPreviewBar-test.tsx b/test/components/views/rooms/RoomPreviewBar-test.tsx index d41b4a338a..8f43e4cdf0 100644 --- a/test/components/views/rooms/RoomPreviewBar-test.tsx +++ b/test/components/views/rooms/RoomPreviewBar-test.tsx @@ -107,7 +107,7 @@ describe('', () => { const component = getComponent({ joining: true }); expect(isSpinnerRendered(component)).toBeTruthy(); - expect(getMessage(component).textContent).toEqual('Joining room …'); + expect(getMessage(component).textContent).toEqual('Joining …'); }); it('renders rejecting message', () => { const component = getComponent({ rejecting: true }); diff --git a/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap index 23824baa15..6a455dc148 100644 --- a/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap +++ b/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap @@ -54,11 +54,11 @@ exports[` with an error renders other errors 1`] = ` RoomPreviewBar-test-room is not accessible at this time.

- Try again later, or ask a room admin to check if you have access. + Try again later, or ask a room or space admin to check if you have access.

- Something_else was returned while trying to access the room. If you think you're seeing this message in error, please + Something_else was returned while trying to access the room or space. If you think you're seeing this message in error, please with an error renders room not found error 1`] = ` RoomPreviewBar-test-room does not exist.

- This room doesn't exist. Are you sure you're at the right place? + Are you sure you're at the right place?

`; @@ -93,7 +93,7 @@ exports[` with an invite with an invited email when client fai Something went wrong with your invite to RoomPreviewBar-test-room

- An error (unknown error code) was returned while trying to validate your invite. You could try to pass this information on to a room admin. + An error (unknown error code) was returned while trying to validate your invite. You could try to pass this information on to the person who invited you.

`; diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index 87b3c4c602..57e66d636b 100644 --- a/test/stores/OwnBeaconStore-test.ts +++ b/test/stores/OwnBeaconStore-test.ts @@ -14,7 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room, Beacon, BeaconEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { + Room, + Beacon, + BeaconEvent, + MatrixEvent, + RoomStateEvent, + RoomMember, +} from "matrix-js-sdk/src/matrix"; import { makeBeaconContent } from "matrix-js-sdk/src/content-helpers"; import { M_BEACON, M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; import { logger } from "matrix-js-sdk/src/logger"; @@ -23,6 +30,7 @@ import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconS import { advanceDateAndTime, flushPromisesWithFakeTimers, + makeMembershipEvent, resetAsyncStoreWithClient, setupAsyncStoreWithClient, } from "../test-utils"; @@ -158,7 +166,7 @@ describe('OwnBeaconStore', () => { geolocation = mockGeolocation(); mockClient.getVisibleRooms.mockReturnValue([]); mockClient.unstable_setLiveBeacon.mockClear().mockResolvedValue({ event_id: '1' }); - mockClient.sendEvent.mockClear().mockResolvedValue({ event_id: '1' }); + mockClient.sendEvent.mockReset().mockResolvedValue({ event_id: '1' }); jest.spyOn(global.Date, 'now').mockReturnValue(now); jest.spyOn(OwnBeaconStore.instance, 'emit').mockRestore(); jest.spyOn(logger, 'error').mockRestore(); @@ -243,6 +251,7 @@ describe('OwnBeaconStore', () => { expect(removeSpy.mock.calls[0]).toEqual(expect.arrayContaining([BeaconEvent.LivenessChange])); expect(removeSpy.mock.calls[1]).toEqual(expect.arrayContaining([BeaconEvent.New])); + expect(removeSpy.mock.calls[2]).toEqual(expect.arrayContaining([RoomStateEvent.Members])); }); it('destroys beacons', async () => { @@ -509,6 +518,112 @@ describe('OwnBeaconStore', () => { }); }); + describe('on room membership changes', () => { + it('ignores events for rooms without beacons', async () => { + const membershipEvent = makeMembershipEvent(room2Id, aliceId); + // no beacons for room2 + const [, room2] = makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + const oldLiveBeaconIds = store.getLiveBeaconIds(); + + mockClient.emit( + RoomStateEvent.Members, + membershipEvent, + room2.currentState, + new RoomMember(room2Id, aliceId), + ); + + expect(emitSpy).not.toHaveBeenCalled(); + // strictly equal + expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds); + }); + + it('ignores events for membership changes that are not current user', async () => { + // bob joins room1 + const membershipEvent = makeMembershipEvent(room1Id, bobId); + const member = new RoomMember(room1Id, bobId); + member.setMembershipEvent(membershipEvent); + + const [room1] = makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + const oldLiveBeaconIds = store.getLiveBeaconIds(); + + mockClient.emit( + RoomStateEvent.Members, + membershipEvent, + room1.currentState, + member, + ); + + expect(emitSpy).not.toHaveBeenCalled(); + // strictly equal + expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds); + }); + + it('ignores events for membership changes that are not leave/ban', async () => { + // alice joins room1 + const membershipEvent = makeMembershipEvent(room1Id, aliceId); + const member = new RoomMember(room1Id, aliceId); + member.setMembershipEvent(membershipEvent); + + const [room1] = makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesRoom2BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + const oldLiveBeaconIds = store.getLiveBeaconIds(); + + mockClient.emit( + RoomStateEvent.Members, + membershipEvent, + room1.currentState, + member, + ); + + expect(emitSpy).not.toHaveBeenCalled(); + // strictly equal + expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds); + }); + + it('destroys and removes beacons when current user leaves room', async () => { + // alice leaves room1 + const membershipEvent = makeMembershipEvent(room1Id, aliceId, 'leave'); + const member = new RoomMember(room1Id, aliceId); + member.setMembershipEvent(membershipEvent); + + const [room1] = makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesRoom2BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const room1BeaconInstance = store.beacons.get(alicesRoom1BeaconInfo.getType()); + const beaconDestroySpy = jest.spyOn(room1BeaconInstance, 'destroy'); + const emitSpy = jest.spyOn(store, 'emit'); + + mockClient.emit( + RoomStateEvent.Members, + membershipEvent, + room1.currentState, + member, + ); + + expect(emitSpy).toHaveBeenCalledWith( + OwnBeaconStoreEvent.LivenessChange, + // other rooms beacons still live + [alicesRoom2BeaconInfo.getType()], + ); + expect(beaconDestroySpy).toHaveBeenCalledTimes(1); + expect(store.getLiveBeaconIds(room1Id)).toEqual([]); + }); + }); + describe('stopBeacon()', () => { beforeEach(() => { makeRoomsWithStateEvents([ @@ -581,7 +696,7 @@ describe('OwnBeaconStore', () => { }); }); - describe('sending positions', () => { + describe('publishing positions', () => { it('stops watching position when user has no more live beacons', async () => { // geolocation is only going to emit 1 position geolocation.watchPosition.mockImplementation( @@ -710,6 +825,141 @@ describe('OwnBeaconStore', () => { }); }); + describe('when publishing position fails', () => { + beforeEach(() => { + geolocation.watchPosition.mockImplementation( + watchPositionMockImplementation([0, 1000, 3000, 3000, 3000]), + ); + + // eat expected console error logs + jest.spyOn(logger, 'error').mockImplementation(() => { }); + }); + + // we need to advance time and then flush promises + // individually for each call to sendEvent + // otherwise the sendEvent doesn't reject/resolve and update state + // before the next call + // advance and flush every 1000ms + // until given ms is 'elapsed' + const advanceAndFlushPromises = async (timeMs: number) => { + while (timeMs > 0) { + jest.advanceTimersByTime(1000); + await flushPromisesWithFakeTimers(); + timeMs -= 1000; + } + }; + + it('continues publishing positions after one publish error', async () => { + // fail to send first event, then succeed + mockClient.sendEvent.mockRejectedValueOnce(new Error('oups')).mockResolvedValue({ event_id: '1' }); + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + await advanceAndFlushPromises(50000); + + // called for each position from watchPosition + expect(mockClient.sendEvent).toHaveBeenCalledTimes(5); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + expect(store.hasWireErrors()).toBe(false); + }); + + it('continues publishing positions when a beacon fails intermittently', async () => { + // every second event rejects + // meaning this beacon has more errors than the threshold + // but they are not consecutive + mockClient.sendEvent + .mockRejectedValueOnce(new Error('oups')) + .mockResolvedValueOnce({ event_id: '1' }) + .mockRejectedValueOnce(new Error('oups')) + .mockResolvedValueOnce({ event_id: '1' }) + .mockRejectedValueOnce(new Error('oups')); + + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + await advanceAndFlushPromises(50000); + + // called for each position from watchPosition + expect(mockClient.sendEvent).toHaveBeenCalledTimes(5); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + expect(store.hasWireErrors()).toBe(false); + expect(emitSpy).not.toHaveBeenCalledWith( + OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(), + ); + }); + + it('stops publishing positions when a beacon fails consistently', async () => { + // always fails to send events + mockClient.sendEvent.mockRejectedValue(new Error('oups')); + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + // 5 positions from watchPosition in this period + await advanceAndFlushPromises(50000); + + // only two allowed failures + expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(true); + expect(store.hasWireErrors()).toBe(true); + expect(emitSpy).toHaveBeenCalledWith( + OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(), + ); + }); + + it('restarts publishing a beacon after resetting wire error', async () => { + // always fails to send events + mockClient.sendEvent.mockRejectedValue(new Error('oups')); + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + // 3 positions from watchPosition in this period + await advanceAndFlushPromises(4000); + + // only two allowed failures + expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(true); + expect(store.hasWireErrors()).toBe(true); + expect(store.hasWireErrors(room1Id)).toBe(true); + expect(emitSpy).toHaveBeenCalledWith( + OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(), + ); + + // reset emitSpy mock counts to asser on wireError again + emitSpy.mockClear(); + store.resetWireError(alicesRoom1BeaconInfo.getType()); + + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + + // 2 more positions from watchPosition in this period + await advanceAndFlushPromises(10000); + + // 2 from before, 2 new ones + expect(mockClient.sendEvent).toHaveBeenCalledTimes(4); + expect(emitSpy).toHaveBeenCalledWith( + OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(), + ); + }); + }); + it('publishes subsequent positions', async () => { // modern fake timers + debounce + promises are not friends // just testing that positions are published diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 44069f10fc..376e907e7a 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -2,6 +2,7 @@ export * from './beacon'; export * from './client'; export * from './location'; export * from './platform'; +export * from './room'; export * from './test-utils'; export * from './voice'; export * from './wrappers'; diff --git a/test/test-utils/room.ts b/test/test-utils/room.ts new file mode 100644 index 0000000000..022f13e6c1 --- /dev/null +++ b/test/test-utils/room.ts @@ -0,0 +1,34 @@ +/* +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 { + EventType, +} from "matrix-js-sdk/src/matrix"; + +import { mkEvent } from "./test-utils"; + +export const makeMembershipEvent = ( + roomId: string, userId: string, membership = 'join', +) => mkEvent({ + event: true, + type: EventType.RoomMember, + room: roomId, + user: userId, + skey: userId, + content: { membership }, + ts: Date.now(), +}); + diff --git a/test/utils/beacon/duration-test.ts b/test/utils/beacon/duration-test.ts index 822860097b..e8a0d36c63 100644 --- a/test/utils/beacon/duration-test.ts +++ b/test/utils/beacon/duration-test.ts @@ -16,7 +16,11 @@ limitations under the License. import { Beacon } from "matrix-js-sdk/src/matrix"; -import { msUntilExpiry, sortBeaconsByLatestExpiry } from "../../../src/utils/beacon"; +import { + msUntilExpiry, + sortBeaconsByLatestExpiry, + sortBeaconsByLatestCreation, +} from "../../../src/utils/beacon"; import { makeBeaconInfoEvent } from "../../test-utils"; describe('beacon utils', () => { @@ -80,4 +84,35 @@ describe('beacon utils', () => { ]); }); }); + + describe('sortBeaconsByLatestCreation()', () => { + const roomId = '!room:server'; + const aliceId = '@alive:server'; + + // 12h old, 12h left + const beacon1 = new Beacon(makeBeaconInfoEvent(aliceId, + roomId, + { timeout: HOUR_MS * 24, timestamp: now - 12 * HOUR_MS }, + '$1', + )); + // 10h left + const beacon2 = new Beacon(makeBeaconInfoEvent(aliceId, + roomId, + { timeout: HOUR_MS * 10, timestamp: now }, + '$2', + )); + + // 1ms left + const beacon3 = new Beacon(makeBeaconInfoEvent(aliceId, + roomId, + { timeout: HOUR_MS + 1, timestamp: now - HOUR_MS }, + '$3', + )); + + it('sorts beacons by descending creation time', () => { + expect([beacon1, beacon2, beacon3].sort(sortBeaconsByLatestCreation)).toEqual([ + beacon2, beacon3, beacon1, + ]); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index a85dc83ec3..62c76d4f99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2197,9 +2197,9 @@ ansi-escapes@^4.2.1: type-fest "^0.21.3" ansi-regex@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" - integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed" + integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== ansi-regex@^5.0.0, ansi-regex@^5.0.1: version "5.0.1"