From 4e665dedb9ea2d3d1ff13db814b831815acb6cce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Mar 2022 00:02:49 -0600 Subject: [PATCH 01/22] Bump ansi-regex from 4.1.0 to 4.1.1 (#8178) Bumps [ansi-regex](https://github.com/chalk/ansi-regex) from 4.1.0 to 4.1.1. - [Release notes](https://github.com/chalk/ansi-regex/releases) - [Commits](https://github.com/chalk/ansi-regex/compare/v4.1.0...v4.1.1) --- updated-dependencies: - dependency-name: ansi-regex dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 02515a76fb..00e139cea3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2215,9 +2215,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" From cd15e08fc285da42134817cce50de8011809cd53 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 29 Mar 2022 07:03:41 +0100 Subject: [PATCH 02/22] Allow sending and thumbnailing AVIF images (#8172) --- src/ContentMessages.ts | 23 ++++++++++++++--------- src/utils/Image.ts | 3 ++- src/utils/blobs.ts | 1 + 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index babe4563f7..e64d88120b 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -249,15 +249,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); 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/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', From 69469e5a982b057ec74e23c210c7f449871f05fc Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 29 Mar 2022 08:04:30 +0200 Subject: [PATCH 03/22] Live location sharing - update copy (#8177) * update settings copy Signed-off-by: Kerry Archibald * i18n Signed-off-by: Kerry Archibald --- src/i18n/strings/en_EN.json | 2 +- src/settings/Settings.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a99a2480a6..32e689029d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -889,7 +889,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", 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": { From c3e02b21cb9b3a7589924b007156a426096a67eb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 29 Mar 2022 13:33:11 +0100 Subject: [PATCH 04/22] Fix issue with redacting via edit composer flow causing stuck editStates (#8184) --- src/Editing.ts | 20 +++++++++++++++++++ src/components/structures/MessagePanel.tsx | 14 +++++++------ src/components/structures/RoomView.tsx | 4 ++-- .../views/rooms/EditMessageComposer.tsx | 8 ++++++-- 4 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 src/Editing.ts 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/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index f7e338fafa..cc6ad9ff5d 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -53,6 +53,7 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer"; import { Action } from '../../dispatcher/actions'; import { getEventDisplayInfo } from "../../utils/EventUtils"; import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker"; +import { editorRoomKey } from "../../Editing"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -307,9 +308,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, }); } @@ -613,13 +615,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; @@ -722,10 +726,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 ffd80e0222..e9ff4e95ca 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -366,7 +366,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), @@ -374,7 +374,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; } diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index b7564a5102..749c2e0bea 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -47,6 +47,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; @@ -224,11 +225,11 @@ class EditMessageComposer extends React.Component { + this.cancelEdit(); + }, }); return; } From e161f0b17bf49401cb865aeb4726fb6287ccea8c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 29 Mar 2022 15:02:12 +0100 Subject: [PATCH 05/22] Update more strings to not wrongly mention room when it is/could be a space (#7722) --- src/RoomInvite.tsx | 2 +- src/components/views/right_panel/UserInfo.tsx | 3 +- src/components/views/rooms/RoomPreviewBar.tsx | 163 +++++++++++------- .../tabs/room/AdvancedRoomSettingsTab.tsx | 17 +- src/i18n/strings/en_EN.json | 50 ++++-- src/stores/RoomViewStore.tsx | 18 +- src/utils/MultiInviter.ts | 32 +++- .../views/rooms/RoomPreviewBar-test.tsx | 2 +- .../RoomPreviewBar-test.tsx.snap | 8 +- 9 files changed, 186 insertions(+), 109 deletions(-) diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index fae3f5dfeb..8d022eb36e 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -125,7 +125,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/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 2aa9046056..61f6ffcf85 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -428,8 +428,7 @@ const UserOptionsSection: React.FC<{ const roomId = member && member.roomId ? member.roomId : RoomViewStore.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/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index 97a1f3e8e4..31af386d3f 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -227,17 +227,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()); } @@ -289,6 +278,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; @@ -304,7 +295,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; } @@ -330,7 +326,7 @@ export default class RoomPreviewBar extends React.Component { footer = (
- { _t("Loading room preview") } + { _t("Loading preview") }
); } @@ -338,37 +334,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) { @@ -381,7 +396,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; @@ -394,14 +409,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.", @@ -412,13 +435,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 }, @@ -428,13 +456,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 }, @@ -460,16 +493,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 }), @@ -502,27 +533,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/settings/tabs/room/AdvancedRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx index 97f388d893..65de287fb7 100644 --- a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx @@ -99,6 +99,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") } ); @@ -130,12 +133,16 @@ export default class AdvancedRoomSettingsTab extends React.Component - { _t("View older messages in %(roomName)s.", { roomName: name }) } + { copy } ); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 32e689029d..69e2773b53 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", "%(space1Name)s and %(space2Name)s": "%(space1Name)s and %(space2Name)s", @@ -694,12 +694,16 @@ "This room is used for important messages from the Homeserver, so you cannot leave it.": "This room is used for important messages from the Homeserver, so you cannot leave it.", "Error leaving room": "Error leaving room", "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", @@ -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", diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 90ba1f8844..b20b2356f9 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -379,14 +379,14 @@ 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) { @@ -395,9 +395,9 @@ 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(`:${MatrixClientPeg.get().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."); } } } @@ -405,8 +405,8 @@ class RoomViewStore extends Store { // FIXME: Using an import will result in test failures const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); 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/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/test/components/views/rooms/RoomPreviewBar-test.tsx b/test/components/views/rooms/RoomPreviewBar-test.tsx index 132b0e1cc9..be47b98c1e 100644 --- a/test/components/views/rooms/RoomPreviewBar-test.tsx +++ b/test/components/views/rooms/RoomPreviewBar-test.tsx @@ -108,7 +108,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.

`; From 2adc972eeca00805d6da6d2ce07e5b591cb76384 Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 29 Mar 2022 18:18:34 +0200 Subject: [PATCH 06/22] Live location sharing - stop sharing to beacons in rooms you left (#8187) * remove beacons on membership changes * add addMembershipToMockedRoom test util Signed-off-by: Kerry Archibald * test remove beacons on membership changes Signed-off-by: Kerry Archibald * removelistener Signed-off-by: Kerry Archibald --- src/stores/OwnBeaconStore.ts | 177 +++++++++++++++++++---------- test/stores/OwnBeaconStore-test.ts | 117 ++++++++++++++++++- test/test-utils/index.ts | 1 + test/test-utils/room.ts | 34 ++++++ 4 files changed, 269 insertions(+), 60 deletions(-) create mode 100644 test/test-utils/room.ts diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index 6f7bec93dd..3404998287 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, @@ -90,6 +93,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()); @@ -102,6 +106,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { 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(); } @@ -136,6 +141,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 +169,33 @@ 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 + */ + private initialiseBeaconState = () => { const userId = this.matrixClient.getUserId(); const visibleRooms = this.matrixClient.getVisibleRooms(); @@ -187,6 +223,21 @@ 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()] @@ -218,20 +269,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 +310,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,6 +324,70 @@ 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 onGeolocationError = async (error: GeolocationError): Promise => { + this.geolocationError = error; + logger.error('Geolocation failed', this.geolocationError); + + // other errors are considered non-fatal + // and self recovering + if (![ + GeolocationError.Unavailable, + GeolocationError.PermissionDenied, + ].includes(error)) { + return; + } + + this.stopPollingLocation(); + // kill live beacons when location permissions are revoked + // 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(); + // TODO error handling + 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 @@ -316,38 +409,4 @@ export class OwnBeaconStore extends AsyncStoreWithClient { 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); - } - }; - - private onGeolocationError = async (error: GeolocationError): Promise => { - this.geolocationError = error; - logger.error('Geolocation failed', this.geolocationError); - - // other errors are considered non-fatal - // and self recovering - if (![ - GeolocationError.Unavailable, - GeolocationError.PermissionDenied, - ].includes(error)) { - return; - } - - this.stopPollingLocation(); - // kill live beacons when location permissions are revoked - // TODO may need adjustment when PSF-797 is done - await Promise.all(this.liveBeaconIds.map(this.stopBeacon)); - }; } diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index 87b3c4c602..d40708109e 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"; @@ -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([ diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 49abc51598..b14bda3cbb 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'; // TODO @@TR: Export voice.ts, which currently isn't exported here because it causes all tests to depend on skinning 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(), +}); + From 5fa2ca83ac0b23f4bb8b7fab4ffa317ff76b4080 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 29 Mar 2022 14:07:35 -0600 Subject: [PATCH 07/22] Allow voice messages to be scrubbed in the timeline (#8079) * Use SeekBar for voice messages + move seeking logic to base class * Appease the linter * Update tests --- .../audio_messages/_PlaybackContainer.scss | 18 +++++-- .../views/audio_messages/AudioPlayer.tsx | 36 +------------ .../views/audio_messages/AudioPlayerBase.tsx | 44 +++++++++++++-- .../audio_messages/RecordingPlayback.tsx | 53 +++++++++++++------ .../views/rooms/VoiceRecordComposerTile.tsx | 2 +- .../audio_messages/RecordingPlayback-test.tsx | 30 +++-------- 6 files changed, 102 insertions(+), 81 deletions(-) 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/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx index 84b96632f1..5f0ccdf450 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 { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -24,41 +24,9 @@ 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"; @replaceableComponent("views.audio_messages.AudioPlayer") 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 1ce0ed1e95..f364350859 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,15 +14,19 @@ 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 { replaceableComponent } from "../../../utils/replaceableComponent"; 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; @@ -36,8 +40,11 @@ interface IState { } @replaceableComponent("views.audio_messages.AudioPlayerBase") -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 @@ -56,6 +63,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 05fca276fe..700231faac 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. @@ -19,29 +19,50 @@ import React, { ReactNode } from "react"; import PlayPauseButton from "./PlayPauseButton"; import PlaybackClock from "./PlaybackClock"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +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"; + +interface IProps extends IAudioPlayerBaseProps { + /** + * When true, use a waveform instead of a seek bar + */ + withWaveform?: boolean; +} @replaceableComponent("views.audio_messages.RecordingPlayback") -export default class RecordingPlayback extends AudioPlayerBase { - static contextType = RoomContext; - public context!: React.ContextType; +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 get isWaveformable(): boolean { - return this.context.timelineRenderingType !== TimelineRenderingType.Notification - && this.context.timelineRenderingType !== TimelineRenderingType.File - && this.context.timelineRenderingType !== TimelineRenderingType.Pinned; + 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/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 448883645e..bed665f8b3 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -233,7 +233,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent; + return ; } // only other UI is the recording-in-progress UI diff --git a/test/components/views/audio_messages/RecordingPlayback-test.tsx b/test/components/views/audio_messages/RecordingPlayback-test.tsx index f8a6c0ef92..4582e7b197 100644 --- a/test/components/views/audio_messages/RecordingPlayback-test.tsx +++ b/test/components/views/audio_messages/RecordingPlayback-test.tsx @@ -27,6 +27,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(), @@ -56,7 +57,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 }, @@ -128,34 +129,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(); }); }); From 4d14128d94c8ba8187acd89deeb61fd466b2f402 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 30 Mar 2022 09:12:43 +0100 Subject: [PATCH 08/22] Fix ts in tests for build (#8189) --- test/components/views/audio_messages/RecordingPlayback-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/components/views/audio_messages/RecordingPlayback-test.tsx b/test/components/views/audio_messages/RecordingPlayback-test.tsx index 4582e7b197..b570bf7243 100644 --- a/test/components/views/audio_messages/RecordingPlayback-test.tsx +++ b/test/components/views/audio_messages/RecordingPlayback-test.tsx @@ -57,7 +57,7 @@ describe('', () => { const mockChannelData = new Float32Array(); const defaultRoom = { roomId: '!room:server.org', timelineRenderingType: TimelineRenderingType.File }; - const getComponent = (props: React.ComponentProps, room = defaultRoom) => + const getComponent = (props: React.ComponentProps, room = defaultRoom) => mount(, { wrappingComponent: RoomContext.Provider, wrappingComponentProps: { value: room }, From 8058f812c2828e2f4c47706938fe3634977d6a49 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 30 Mar 2022 12:43:54 +0100 Subject: [PATCH 09/22] Show room preview bar with maximised widgets (#8180) --- res/css/views/right_panel/_TimelineCard.scss | 13 ++++---- res/css/views/rooms/_RoomPreviewBar.scss | 10 ++++++- src/components/structures/RoomView.tsx | 30 ++++++++++++------- .../views/right_panel/TimelineCard.tsx | 23 ++++++++------ 4 files changed, 50 insertions(+), 26 deletions(-) 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/_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/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index e9ff4e95ca..fe9f85c900 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1983,11 +1983,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; @@ -2162,6 +2171,7 @@ export class RoomView extends React.Component { onForgetClick = null; onSearchClick = null; } + return (
@@ -2183,7 +2193,7 @@ export class RoomView extends React.Component { excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} /> -
+
{ mainSplitBody }
diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index bd7d4361cb..b0c83df43e 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -221,6 +221,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 && ( + + ) } ); From be8665af4d5dcd015ad690a0284f0bf6b2fd7037 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 30 Mar 2022 08:02:34 -0400 Subject: [PATCH 10/22] Fix coverage diffs for PRs that aren't up to date, take 2 (#8188) --- .github/workflows/test_coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml index 6eb4d883bd..e01fd6ebcc 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 From e721c6b0c25fd3228465b19040b903a71adfb84b Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 30 Mar 2022 14:31:19 +0200 Subject: [PATCH 11/22] Live location sharing: allow retry when stop sharing fails (#8193) * allow retry when stop sharing fails Signed-off-by: Kerry Archibald * tidy Signed-off-by: Kerry Archibald --- .../views/beacon/_StyledLiveBeaconIcon.scss | 5 ++++ .../views/beacon/RoomLiveShareWarning.tsx | 23 +++++++++++----- .../views/beacon/StyledLiveBeaconIcon.tsx | 8 ++++-- src/i18n/strings/en_EN.json | 1 + src/stores/OwnBeaconStore.ts | 15 ++++++++--- .../beacon/RoomLiveShareWarning-test.tsx | 27 ++++++++++++++++++- .../RoomLiveShareWarning-test.tsx.snap | 2 ++ 7 files changed, 68 insertions(+), 13 deletions(-) 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/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index 24c0b345cc..0f6b0ee809 100644 --- a/src/components/views/beacon/RoomLiveShareWarning.tsx +++ b/src/components/views/beacon/RoomLiveShareWarning.tsx @@ -73,9 +73,11 @@ type LiveBeaconsState = { beacon?: Beacon; onStopSharing?: () => void; stoppingInProgress?: boolean; + hasStopSharingError?: boolean; }; const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => { const [stoppingInProgress, setStoppingInProgress] = useState(false); + const [error, setError] = useState(); // do we have an active geolocation.watchPosition const isMonitoringLiveLocation = useEventEmitterState( @@ -93,6 +95,7 @@ const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => { // reset stopping in progress on change in live ids useEffect(() => { setStoppingInProgress(false); + setError(undefined); }, [liveBeaconIds]); if (!isMonitoringLiveLocation || !liveBeaconIds?.length) { @@ -112,11 +115,12 @@ 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 }; + return { onStopSharing, beacon, stoppingInProgress, hasStopSharingError: !!error }; }; const LiveTimeRemaining: React.FC<{ beacon: Beacon }> = ({ beacon }) => { @@ -136,6 +140,7 @@ const RoomLiveShareWarning: React.FC = ({ roomId }) => { onStopSharing, beacon, stoppingInProgress, + hasStopSharingError, } = useLiveBeacons(roomId); if (!beacon) { @@ -145,15 +150,19 @@ const RoomLiveShareWarning: React.FC = ({ roomId }) => { return
- + - { _t('You are sharing your live location') } + { hasStopSharingError ? + _t('An error occurred while stopping your live location, please try again') : + _t('You are sharing your live location') + } - { stoppingInProgress ? - : - + { stoppingInProgress && + } + { !stoppingInProgress && !hasStopSharingError && } + = ({ roomId }) => { element='button' disabled={stoppingInProgress} > - { _t('Stop sharing') } + { hasStopSharingError ? _t('Retry') : _t('Stop sharing') }
; }; 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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 69e2773b53..eee6e7075c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2898,6 +2898,7 @@ "Join the beta": "Join the beta", "You are sharing your live location": "You are sharing your live location", "%(timeRemaining)s left": "%(timeRemaining)s left", + "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", "Avatar": "Avatar", "This room is public": "This room is public", diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index 3404998287..b6ad63b9c7 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -55,6 +55,7 @@ const STATIC_UPDATE_INTERVAL = 30000; type OwnBeaconStoreState = { beacons: Map; + beaconWireErrors: Map; beaconsByRoomId: Map>; liveBeaconIds: string[]; }; @@ -63,6 +64,10 @@ 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 beacons + */ + public readonly beaconWireErrors = new Map(); private liveBeaconIds = []; private locationInterval: number; private geolocationError: GeolocationError | undefined; @@ -101,6 +106,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.beacons.clear(); this.beaconsByRoomId.clear(); this.liveBeaconIds = []; + this.beaconWireErrors.clear(); } protected async onReady(): Promise { @@ -362,7 +368,6 @@ export class OwnBeaconStore extends AsyncStoreWithClient { private publishCurrentLocationToBeacons = async () => { try { const position = await getCurrentPosition(); - // TODO error handling this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position)); } catch (error) { this.onGeolocationError(error?.message); @@ -394,7 +399,6 @@ export class OwnBeaconStore extends AsyncStoreWithClient { */ 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)), ); @@ -407,6 +411,11 @@ export class OwnBeaconStore extends AsyncStoreWithClient { */ 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); + try { + await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content); + } catch (error) { + logger.error(error); + this.beaconWireErrors.set(beacon.identifier, error); + } }; } diff --git a/test/components/views/beacon/RoomLiveShareWarning-test.tsx b/test/components/views/beacon/RoomLiveShareWarning-test.tsx index 93835c21a3..d549e9a51e 100644 --- a/test/components/views/beacon/RoomLiveShareWarning-test.tsx +++ b/test/components/views/beacon/RoomLiveShareWarning-test.tsx @@ -26,6 +26,7 @@ import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../../src/stores/OwnB import { advanceDateAndTime, findByTestId, + flushPromisesWithFakeTimers, getMockClientWithEventEmitter, makeBeaconInfoEvent, mockGeolocation, @@ -96,7 +97,7 @@ describe('', () => { beforeEach(() => { mockGeolocation(); jest.spyOn(global.Date, 'now').mockReturnValue(now); - mockClient.unstable_setLiveBeacon.mockClear(); + mockClient.unstable_setLiveBeacon.mockReset().mockResolvedValue({ event_id: '1' }); }); afterEach(async () => { @@ -246,6 +247,30 @@ describe('', () => { expect(findByTestId(component, 'room-live-share-stop-sharing').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-stop-sharing').at(0).simulate('click'); + await flushPromisesWithFakeTimers(); + }); + component.setProps({}); + + expect(component.html()).toMatchSnapshot(); + + act(() => { + findByTestId(component, 'room-live-share-stop-sharing').at(0).simulate('click'); + component.setProps({}); + }); + + expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledTimes(2); + }); + it('displays again with correct state after stopping a beacon', () => { // make sure the loading state is reset correctly after removing a beacon const component = getComponent({ roomId: room1Id }); diff --git a/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap index 786827fdea..0f76512445 100644 --- a/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap @@ -3,3 +3,5 @@ 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 stopping beacons displays error when stop sharing fails 1`] = `"
An error occurred while stopping your live location, please try again
"`; From 31cd7edd332ece4463f392d2ff90ccd75c75508d Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 30 Mar 2022 14:20:20 +0100 Subject: [PATCH 12/22] Reset room event_id fragment when ThreadView unmounts (#8186) --- src/components/structures/ThreadView.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 13fdc2d31a..e91c1ae9cc 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -51,6 +51,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; @@ -106,9 +107,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) { @@ -206,7 +217,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, @@ -363,7 +374,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} />
} From d09205122d148f7d98201c257cd1a166dababa09 Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 30 Mar 2022 16:01:44 +0200 Subject: [PATCH 13/22] Live location sharing - Stop publishing location to beacons with consecutive errors (#8194) * add error state after consecutive errors Signed-off-by: Kerry Archibald * polish Signed-off-by: Kerry Archibald * comment Signed-off-by: Kerry Archibald * remove debug Signed-off-by: Kerry Archibald --- src/stores/OwnBeaconStore.ts | 67 +++++++++++++-- test/stores/OwnBeaconStore-test.ts | 134 ++++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 7 deletions(-) diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index b6ad63b9c7..e4c0d46a6d 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -48,11 +48,14 @@ 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; @@ -65,9 +68,11 @@ export class OwnBeaconStore extends AsyncStoreWithClient { public readonly beacons = new Map(); public readonly beaconsByRoomId = new Map>(); /** - * Track over the wire errors for beacons + * Track over the wire errors for published positions + * Counts consecutive wire errors per beacon + * Reset on successful publish of location */ - public readonly beaconWireErrors = new Map(); + public readonly beaconWireErrorCounts = new Map(); private liveBeaconIds = []; private locationInterval: number; private geolocationError: GeolocationError | undefined; @@ -106,7 +111,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.beacons.clear(); this.beaconsByRoomId.clear(); this.liveBeaconIds = []; - this.beaconWireErrors.clear(); + this.beaconWireErrorCounts.clear(); } protected async onReady(): Promise { @@ -125,6 +130,25 @@ export class OwnBeaconStore extends AsyncStoreWithClient { return !!this.getLiveBeaconIds(roomId).length; } + /** + * 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 hasWireError(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; @@ -202,6 +226,13 @@ export class OwnBeaconStore extends AsyncStoreWithClient { * State management */ + /** + * Live beacon ids that do not have wire errors + */ + private get healthyLiveBeaconIds() { + return this.liveBeaconIds.filter(beaconId => !this.hasWireError(beaconId)); + } + private initialiseBeaconState = () => { const userId = this.matrixClient.getUserId(); const visibleRooms = this.matrixClient.getVisibleRooms(); @@ -399,7 +430,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { */ private publishLocationToBeacons = async (position: TimedGeoUri) => { this.lastPublishedPositionTimestamp = Date.now(); - await Promise.all(this.liveBeaconIds.map(beaconId => + await Promise.all(this.healthyLiveBeaconIds.map(beaconId => this.sendLocationToBeacon(this.beacons.get(beaconId), position)), ); }; @@ -413,9 +444,35 @@ export class OwnBeaconStore extends AsyncStoreWithClient { 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.beaconWireErrors.set(beacon.identifier, 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.hasWireError(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.hasWireError(beaconId) !== hadError) { + this.emit(OwnBeaconStoreEvent.WireError, beaconId); } }; } diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index d40708109e..3c924594f5 100644 --- a/test/stores/OwnBeaconStore-test.ts +++ b/test/stores/OwnBeaconStore-test.ts @@ -166,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(); @@ -696,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( @@ -825,6 +825,136 @@ 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.hasWireError(alicesRoom1BeaconInfo.getType())).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.hasWireError(alicesRoom1BeaconInfo.getType())).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.hasWireError(alicesRoom1BeaconInfo.getType())).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.hasWireError(alicesRoom1BeaconInfo.getType())).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.hasWireError(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 From 60ca8996d3a11cf1a0f1d9e617370d793baeeba5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 30 Mar 2022 16:53:15 +0100 Subject: [PATCH 14/22] Fix issue with replying outside a thread to a thread root (#8195) --- src/utils/Reply.ts | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/src/utils/Reply.ts b/src/utils/Reply.ts index 43144a34a1..1873ca38b3 100644 --- a/src/utils/Reply.ts +++ b/src/utils/Reply.ts @@ -147,30 +147,13 @@ export function getNestedReplyText( export function makeReplyMixIn(ev?: MatrixEvent): RecursivePartial { if (!ev) return {}; - const mixin: RecursivePartial = { + return { 'm.relates_to': { '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, - }; - } - - return mixin; } export function shouldDisplayReply(event: MatrixEvent): boolean { @@ -210,8 +193,7 @@ export function addReplyToMessageContent( Object.assign(content, replyContent); 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) { From 1175226bcb4045f8f302dd59c41ed29f90fd0ae1 Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 31 Mar 2022 10:57:12 +0200 Subject: [PATCH 15/22] Live location sharing - display wire error in room (#8198) * expose wire errors in more useful way * add wire error state to room live share warning bar Signed-off-by: Kerry Archibald * stylelint Signed-off-by: Kerry Archibald * add types to getLabel helper Signed-off-by: Kerry Archibald --- .../views/beacon/_RoomLiveShareWarning.scss | 10 ++ .../views/beacon/RoomLiveShareWarning.tsx | 133 +++++++++++++----- src/i18n/strings/en_EN.json | 2 + src/stores/OwnBeaconStore.ts | 22 ++- .../beacon/RoomLiveShareWarning-test.tsx | 87 +++++++++++- .../RoomLiveShareWarning-test.tsx.snap | 85 ++++++++++- test/stores/OwnBeaconStore-test.ts | 15 +- 7 files changed, 296 insertions(+), 58 deletions(-) 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/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index 0f6b0ee809..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,24 +69,20 @@ 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 @@ -98,10 +91,6 @@ const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => { 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) @@ -120,7 +109,18 @@ const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => { } }; - return { onStopSharing, beacon, stoppingInProgress, hasStopSharingError: !!error }; + const onResetWireError = () => { + liveBeaconIds.map(beaconId => OwnBeaconStore.instance.resetWireError(beaconId)); + }; + + return { + onStopSharing, + onResetWireError, + beacon, + stoppingInProgress, + hasWireError, + hasStopSharingError: !!error, + }; }; const LiveTimeRemaining: React.FC<{ beacon: Beacon }> = ({ beacon }) => { @@ -135,44 +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, hasStopSharingError, - } = useLiveBeacons(roomId); + hasWireError, + } = useLiveBeacons(liveBeaconIds, roomId); if (!beacon) { return null; } + const hasError = hasStopSharingError || hasWireError; + + const onButtonClick = () => { + if (hasWireError) { + onResetWireError(); + } else { + onStopSharing(); + } + }; + return
- + + - { hasStopSharingError ? - _t('An error occurred while stopping your live location, please try again') : - _t('You are sharing your live location') - } + { getLabel(hasWireError, hasStopSharingError) } { stoppingInProgress && } - { !stoppingInProgress && !hasStopSharingError && } + { !stoppingInProgress && !hasError && } - { hasStopSharingError ? _t('Retry') : _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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index eee6e7075c..32d1500377 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2898,8 +2898,10 @@ "Join the beta": "Join the beta", "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/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index e4c0d46a6d..3bc6e78469 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -130,16 +130,24 @@ export class OwnBeaconStore extends AsyncStoreWithClient { return !!this.getLiveBeaconIds(roomId).length; } + /** + * 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 hasWireError(beaconId: string): boolean { + public beaconHasWireError = (beaconId: string): boolean => { return this.beaconWireErrorCounts.get(beaconId) >= BAIL_AFTER_CONSECUTIVE_ERROR_COUNT; - } + }; - public resetWireError(beaconId: string): void { + public resetWireError = (beaconId: string): void => { this.incrementBeaconWireErrorCount(beaconId, false); // always publish to all live beacons together @@ -147,7 +155,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { // to keep lastPublishedTimestamp simple // and extra published locations don't hurt this.publishCurrentLocationToBeacons(); - } + }; public getLiveBeaconIds(roomId?: string): string[] { if (!roomId) { @@ -230,7 +238,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { * Live beacon ids that do not have wire errors */ private get healthyLiveBeaconIds() { - return this.liveBeaconIds.filter(beaconId => !this.hasWireError(beaconId)); + return this.liveBeaconIds.filter(beaconId => !this.beaconHasWireError(beaconId)); } private initialiseBeaconState = () => { @@ -458,7 +466,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { * - emit if beacon error count crossed threshold */ private incrementBeaconWireErrorCount = (beaconId: string, isError: boolean): void => { - const hadError = this.hasWireError(beaconId); + const hadError = this.beaconHasWireError(beaconId); if (isError) { // increment error count @@ -471,7 +479,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.beaconWireErrorCounts.delete(beaconId); } - if (this.hasWireError(beaconId) !== hadError) { + if (this.beaconHasWireError(beaconId) !== hadError) { this.emit(OwnBeaconStoreEvent.WireError, beaconId); } }; diff --git a/test/components/views/beacon/RoomLiveShareWarning-test.tsx b/test/components/views/beacon/RoomLiveShareWarning-test.tsx index d549e9a51e..97cc953d52 100644 --- a/test/components/views/beacon/RoomLiveShareWarning-test.tsx +++ b/test/components/views/beacon/RoomLiveShareWarning-test.tsx @@ -101,6 +101,7 @@ describe('', () => { }); afterEach(async () => { + jest.spyOn(OwnBeaconStore.instance, 'hasWireErrors').mockRestore(); await resetAsyncStoreWithClient(OwnBeaconStore.instance); }); @@ -238,13 +239,13 @@ 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 () => { @@ -256,7 +257,7 @@ describe('', () => { .mockResolvedValue(({ event_id: '1' })); await act(async () => { - findByTestId(component, 'room-live-share-stop-sharing').at(0).simulate('click'); + findByTestId(component, 'room-live-share-primary-button').at(0).simulate('click'); await flushPromisesWithFakeTimers(); }); component.setProps({}); @@ -264,7 +265,7 @@ describe('', () => { expect(component.html()).toMatchSnapshot(); 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({}); }); @@ -277,7 +278,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(() => { @@ -293,9 +294,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__/RoomLiveShareWarning-test.tsx.snap b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap index 0f76512445..8ae076a2a1 100644 --- a/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap @@ -1,7 +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 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/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index 3c924594f5..57e66d636b 100644 --- a/test/stores/OwnBeaconStore-test.ts +++ b/test/stores/OwnBeaconStore-test.ts @@ -863,7 +863,8 @@ describe('OwnBeaconStore', () => { // called for each position from watchPosition expect(mockClient.sendEvent).toHaveBeenCalledTimes(5); - expect(store.hasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + expect(store.hasWireErrors()).toBe(false); }); it('continues publishing positions when a beacon fails intermittently', async () => { @@ -889,7 +890,8 @@ describe('OwnBeaconStore', () => { // called for each position from watchPosition expect(mockClient.sendEvent).toHaveBeenCalledTimes(5); - expect(store.hasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + expect(store.hasWireErrors()).toBe(false); expect(emitSpy).not.toHaveBeenCalledWith( OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(), ); @@ -911,7 +913,8 @@ describe('OwnBeaconStore', () => { // only two allowed failures expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); - expect(store.hasWireError(alicesRoom1BeaconInfo.getType())).toBe(true); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(true); + expect(store.hasWireErrors()).toBe(true); expect(emitSpy).toHaveBeenCalledWith( OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(), ); @@ -933,7 +936,9 @@ describe('OwnBeaconStore', () => { // only two allowed failures expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); - expect(store.hasWireError(alicesRoom1BeaconInfo.getType())).toBe(true); + 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(), ); @@ -942,7 +947,7 @@ describe('OwnBeaconStore', () => { emitSpy.mockClear(); store.resetWireError(alicesRoom1BeaconInfo.getType()); - expect(store.hasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); // 2 more positions from watchPosition in this period await advanceAndFlushPromises(10000); From 4922e19b5aa6d74845aa61bb94ec44825a82e6cc Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 31 Mar 2022 13:51:44 +0200 Subject: [PATCH 16/22] Live Location Sharing - left panel warning with error (#8201) * add error style to left panel beacon warning Signed-off-by: Kerry Archibald * test Signed-off-by: Kerry Archibald * add beacon sort util * link to latest beacon room from left panel warning Signed-off-by: Kerry Archibald --- .../beacon/_LeftPanelLiveShareWarning.scss | 5 + .../beacon/LeftPanelLiveShareWarning.tsx | 58 ++++++++- src/i18n/strings/en_EN.json | 1 + src/stores/OwnBeaconStore.ts | 26 ++-- src/utils/beacon/duration.ts | 4 + .../beacon/LeftPanelLiveShareWarning-test.tsx | 118 +++++++++++++++++- .../LeftPanelLiveShareWarning-test.tsx.snap | 64 ++++++++-- test/utils/beacon/duration-test.ts | 37 +++++- 8 files changed, 289 insertions(+), 24 deletions(-) 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/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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 32d1500377..05182353e2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2896,6 +2896,7 @@ "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", diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index 3bc6e78469..31ddd42b85 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -38,6 +38,7 @@ import { ClearWatchCallback, GeolocationError, mapGeolocationPositionToTimedGeo, + sortBeaconsByLatestCreation, TimedGeoUri, watchPosition, } from "../utils/beacon"; @@ -73,6 +74,10 @@ export class OwnBeaconStore extends AsyncStoreWithClient { * 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; @@ -126,17 +131,17 @@ 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; - } + }; /** * Some live beacon has a wire error * Optionally filter by room */ - public hasWireErrors(roomId?: string): boolean { + public hasWireErrors = (roomId?: string): boolean => { return this.getLiveBeaconIds(roomId).some(this.beaconHasWireError); - } + }; /** * If a beacon has failed to publish position @@ -157,16 +162,20 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.publishCurrentLocationToBeacons(); }; - public getLiveBeaconIds(roomId?: string): string[] { + 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); @@ -287,6 +296,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { 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); 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/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx index 7ad06fcf12..29e52e233e 100644 --- a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx +++ b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx @@ -17,17 +17,23 @@ 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 '../../../skinned-sdk'; 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 @@ -44,32 +50,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/__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/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, + ]); + }); + }); }); From 90527316cf9aa2cd7a2a45cd05d2a678685932ca Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 31 Mar 2022 13:23:03 +0100 Subject: [PATCH 17/22] Don't try show saved thread panel state if threads is disabled (#8203) --- src/stores/right-panel/RightPanelStore.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 5a86dd38c5..7b10782925 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -249,6 +249,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) { @@ -259,7 +262,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"); } From 215f89d76a802f4b265a893b840adebdb7a9a183 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 31 Mar 2022 14:37:31 +0100 Subject: [PATCH 18/22] Fix alignment of UISIs in threads (#8206) --- res/css/views/rooms/_EventTile.scss | 1 + 1 file changed, 1 insertion(+) 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; From 3b388b7fae37fab06737af993cb3945e9bd371a0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 31 Mar 2022 14:43:29 +0100 Subject: [PATCH 19/22] Use appropriate Member object when rendering thread summary (#8204) --- src/components/views/rooms/ThreadSummary.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/ThreadSummary.tsx b/src/components/views/rooms/ThreadSummary.tsx index 9adec2e1ac..0c923606de 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() }
}
From f4c25e06cdbc2a5e0fab6abdf65181753d015d42 Mon Sep 17 00:00:00 2001 From: Germain Date: Thu, 31 Mar 2022 14:48:23 +0100 Subject: [PATCH 20/22] Fix notification dot for "Mentions & keywords" for thread messages (#8202) --- src/Unread.ts | 25 +++++++++++++------ .../notifications/RoomNotificationState.ts | 15 +++++++++-- .../RoomNotificationStateStore.ts | 5 ++-- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/Unread.ts b/src/Unread.ts index 91e192b371..3dfd63614c 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 { haveTileForEvent } from "./components/views/rooms/EventTile"; +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/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); } From 17cfd45eb3b59a5e240f9c0d58e742d920e87556 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 31 Mar 2022 18:40:35 +0100 Subject: [PATCH 21/22] Fix explicit replies in threads (#8210) --- src/ContentMessages.ts | 6 ++--- .../views/rooms/SendMessageComposer.tsx | 18 ++++--------- src/utils/Reply.ts | 25 +++++++++++-------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index e64d88120b..6fb4b1f2ce 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -47,6 +47,7 @@ import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerform import { TimelineRenderingType } from "./contexts/RoomContext"; import RoomViewStore from "./stores/RoomViewStore"; import { addReplyToMessageContent } from "./utils/Reply"; +import { attachRelation } from "./components/views/rooms/SendMessageComposer"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; @@ -585,10 +586,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/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 2bbf8294f7..c275e8b295 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -60,14 +60,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, }; } } @@ -100,6 +98,7 @@ export function createMessageContent( content.formatted_body = formattedBody; } + attachRelation(content, relation); if (replyToEvent) { addReplyToMessageContent(content, replyToEvent, { permalinkCreator, @@ -107,13 +106,6 @@ export function createMessageContent( }); } - if (relation) { - content['m.relates_to'] = { - ...relation, - ...content['m.relates_to'], - }; - } - return content; } diff --git a/src/utils/Reply.ts b/src/utils/Reply.ts index 1873ca38b3..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,16 +143,20 @@ export function getNestedReplyText( return { body, html }; } -export function makeReplyMixIn(ev?: MatrixEvent): RecursivePartial { +export function makeReplyMixIn(ev?: MatrixEvent): IEventRelation { if (!ev) return {}; - return { - 'm.relates_to': { - 'm.in_reply_to': { - 'event_id': ev.getId(), - }, + const mixin: IEventRelation = { + 'm.in_reply_to': { + 'event_id': ev.getId(), }, }; + + if (SettingsStore.getValue("feature_thread") && ev.threadRootId) { + mixin.is_falling_back = false; + } + + return mixin; } export function shouldDisplayReply(event: MatrixEvent): boolean { @@ -189,8 +192,10 @@ 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 From 04e79dffae77cec0f6072c01e404f9bf5c30dcd5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 31 Mar 2022 18:40:51 +0100 Subject: [PATCH 22/22] Fix editing
    tags with a non-1 start attribute (#8211) --- src/editor/deserialize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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++;