diff --git a/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss b/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss index 0ee60a65f27..04645c965ed 100644 --- a/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss +++ b/res/css/components/views/beacon/_LeftPanelLiveShareWarning.scss @@ -15,6 +15,7 @@ limitations under the License. */ .mx_LeftPanelLiveShareWarning { + @mixin ButtonResetDefault; width: 100%; box-sizing: border-box; @@ -29,3 +30,7 @@ limitations under the License. // go above to get hover for title z-index: 1; } + +.mx_LeftPanelLiveShareWarning__error { + background-color: $alert; +} diff --git a/res/css/components/views/beacon/_RoomLiveShareWarning.scss b/res/css/components/views/beacon/_RoomLiveShareWarning.scss index c0d5ea47fe1..7404f88aea3 100644 --- a/res/css/components/views/beacon/_RoomLiveShareWarning.scss +++ b/res/css/components/views/beacon/_RoomLiveShareWarning.scss @@ -48,3 +48,13 @@ limitations under the License. .mx_RoomLiveShareWarning_spinner { margin-right: $spacing-16; } + +.mx_RoomLiveShareWarning_closeButton { + @mixin ButtonResetDefault; + margin-left: $spacing-16; +} + +.mx_RoomLiveShareWarning_closeButtonIcon { + height: $font-18px; + padding: $spacing-4; +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 310465837bd..bb60c95c99e 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -970,6 +970,7 @@ $threadInfoLineHeight: calc(2 * $font-12px); .mx_EventTile_content, .mx_HiddenBody, .mx_RedactedBody, + .mx_UnknownBody, .mx_MPollBody, .mx_ReplyChain_wrapper { margin-left: 36px; diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index e64d88120b6..6fb4b1f2ce7 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/Unread.ts b/src/Unread.ts index 91e192b371d..3dfd63614cc 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/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 13fdc2d31a3..e91c1ae9cca 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} /> } diff --git a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx index 2b4b7eb70ee..07ba4cd2369 100644 --- a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx +++ b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx @@ -21,11 +21,31 @@ import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { _t } from '../../../languageHandler'; import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore'; import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg'; +import { ViewRoomPayload } from '../../../dispatcher/payloads/ViewRoomPayload'; +import { Action } from '../../../dispatcher/actions'; +import dispatcher from '../../../dispatcher/dispatcher'; +import AccessibleButton from '../elements/AccessibleButton'; interface Props { isMinimized?: boolean; } +/** + * Choose the most relevant beacon + * and get its roomId + */ +const chooseBestBeaconRoomId = (liveBeaconIds, errorBeaconIds): string | undefined => { + // both lists are ordered by creation timestamp in store + // so select latest beacon + const beaconId = errorBeaconIds?.[0] ?? liveBeaconIds?.[0]; + if (!beaconId) { + return undefined; + } + const beacon = OwnBeaconStore.instance.getBeaconById(beaconId); + + return beacon?.roomId; +}; + const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { const isMonitoringLiveLocation = useEventEmitterState( OwnBeaconStore.instance, @@ -33,18 +53,48 @@ const LeftPanelLiveShareWarning: React.FC = ({ isMinimized }) => { () => OwnBeaconStore.instance.isMonitoringLiveLocation, ); + const beaconIdsWithWireError = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.WireError, + () => OwnBeaconStore.instance.getLiveBeaconIdsWithWireError(), + ); + + const liveBeaconIds = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.LivenessChange, + () => OwnBeaconStore.instance.getLiveBeaconIds(), + ); + + const hasWireErrors = !!beaconIdsWithWireError.length; + if (!isMonitoringLiveLocation) { return null; } - return
{ + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: relevantBeaconRoomId, + metricsTrigger: undefined, + }); + } : undefined; + + const label = hasWireErrors ? + _t('An error occured whilst sharing your live location') : + _t('You are sharing your live location'); + + return - { isMinimized ? : _t('You are sharing your live location') } -
; + { isMinimized ? : label } + ; }; export default LeftPanelLiveShareWarning; diff --git a/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index 0f6b0ee8090..d51c22f6449 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 { useInterval } from '../../../hooks/useTimeout'; 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 AccessibleButton from '../elements/AccessibleButton'; import Spinner from '../elements/Spinner'; -import { useInterval } from '../../../hooks/useTimeout'; - -interface Props { - roomId: Room['roomId']; -} +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( - OwnBeaconStore.instance, - OwnBeaconStoreEvent.MonitoringLivePosition, - () => OwnBeaconStore.instance.isMonitoringLiveLocation, - ); - - const liveBeaconIds = useEventEmitterState( + const hasWireError = 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/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 2bbf8294f7e..c275e8b2954 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/components/views/rooms/ThreadSummary.tsx b/src/components/views/rooms/ThreadSummary.tsx index 9adec2e1ace..0c923606de8 100644 --- a/src/components/views/rooms/ThreadSummary.tsx +++ b/src/components/views/rooms/ThreadSummary.tsx @@ -90,17 +90,16 @@ export const ThreadMessagePreview = ({ thread, showDisplayname = false }: IPrevi }, [lastReply, replacingEventId]); if (!preview) return null; - const sender = thread.roomState.getSentinelMember(lastReply.getSender()); return <> { showDisplayname &&
- { sender?.name ?? lastReply.getSender() } + { lastReply.sender?.name ?? lastReply.getSender() }
}
diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts index b3184f63f08..3b5c5e638b3 100644 --- a/src/editor/deserialize.ts +++ b/src/editor/deserialize.ts @@ -218,7 +218,7 @@ function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]): return parts; } case "OL": { - let counter = 1; + let counter = (n as HTMLOListElement).start ?? 1; const parts = parseChildren(n, pc, li => { const parts = [pc.plain(`${counter}. `), ...parseChildren(li, pc)]; counter++; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index eee6e7075c1..05182353e2f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2896,10 +2896,13 @@ "Beta": "Beta", "Leave the beta": "Leave the beta", "Join the beta": "Join the beta", + "An error occured whilst sharing your live location": "An error occured whilst sharing your live location", "You are sharing your live location": "You are sharing your live location", "%(timeRemaining)s left": "%(timeRemaining)s left", + "An error occured whilst sharing your live location, please try again": "An error occured whilst sharing your live location, please try again", "An error occurred while stopping your live location, please try again": "An error occurred while stopping your live location, please try again", "Stop sharing": "Stop sharing", + "Stop sharing and close": "Stop sharing and close", "Avatar": "Avatar", "This room is public": "This room is public", "Away": "Away", diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index b6ad63b9c75..31ddd42b858 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"; @@ -48,11 +49,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 +69,15 @@ 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 beaconWireErrorCounts = new Map(); + /** + * ids of live beacons + * ordered by creation time descending */ - public readonly beaconWireErrors = new Map(); private liveBeaconIds = []; private locationInterval: number; private geolocationError: GeolocationError | undefined; @@ -106,7 +116,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.beacons.clear(); this.beaconsByRoomId.clear(); this.liveBeaconIds = []; - this.beaconWireErrors.clear(); + this.beaconWireErrorCounts.clear(); } protected async onReady(): Promise { @@ -121,20 +131,51 @@ export class OwnBeaconStore extends AsyncStoreWithClient { // we don't actually do anything here } - public hasLiveBeacons(roomId?: string): boolean { + public hasLiveBeacons = (roomId?: string): boolean => { return !!this.getLiveBeaconIds(roomId).length; - } + }; + + /** + * Some live beacon has a wire error + * Optionally filter by room + */ + public hasWireErrors = (roomId?: string): boolean => { + return this.getLiveBeaconIds(roomId).some(this.beaconHasWireError); + }; - public getLiveBeaconIds(roomId?: string): string[] { + /** + * If a beacon has failed to publish position + * past the allowed consecutive failure count (BAIL_AFTER_CONSECUTIVE_ERROR_COUNT) + * Then consider it to have an error + */ + public beaconHasWireError = (beaconId: string): boolean => { + return this.beaconWireErrorCounts.get(beaconId) >= BAIL_AFTER_CONSECUTIVE_ERROR_COUNT; + }; + + public resetWireError = (beaconId: string): void => { + this.incrementBeaconWireErrorCount(beaconId, false); + + // always publish to all live beacons together + // instead of just one that was changed + // to keep lastPublishedTimestamp simple + // and extra published locations don't hurt + this.publishCurrentLocationToBeacons(); + }; + + public getLiveBeaconIds = (roomId?: string): string[] => { if (!roomId) { return this.liveBeaconIds; } return this.liveBeaconIds.filter(beaconId => this.beaconsByRoomId.get(roomId)?.has(beaconId)); - } + }; - public getBeaconById(beaconId: string): Beacon | undefined { + public getLiveBeaconIdsWithWireError = (roomId?: string): string[] => { + return this.getLiveBeaconIds(roomId).filter(this.beaconHasWireError); + }; + + public getBeaconById = (beaconId: string): Beacon | undefined => { return this.beacons.get(beaconId); - } + }; public stopBeacon = async (beaconInfoType: string): Promise => { const beacon = this.beacons.get(beaconInfoType); @@ -202,6 +243,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.beaconHasWireError(beaconId)); + } + private initialiseBeaconState = () => { const userId = this.matrixClient.getUserId(); const visibleRooms = this.matrixClient.getVisibleRooms(); @@ -248,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); @@ -399,7 +448,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 +462,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.beaconHasWireError(beaconId); + + if (isError) { + // increment error count + this.beaconWireErrorCounts.set( + beaconId, + (this.beaconWireErrorCounts.get(beaconId) ?? 0) + 1, + ); + } else { + // clear any error count + this.beaconWireErrorCounts.delete(beaconId); + } + + if (this.beaconHasWireError(beaconId) !== hadError) { + this.emit(OwnBeaconStoreEvent.WireError, beaconId); } }; } diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index 517a23fa97b..c4c803483df 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 60907973844..887e1a7332c 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -82,12 +82,13 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { */ public getRoomState(room: Room): RoomNotificationState { if (!this.roomMap.has(room)) { - this.roomMap.set(room, new RoomNotificationState(room)); // Not very elegant, but that way we ensure that we start tracking // threads notification at the same time at rooms. // There are multiple entry points, and it's unclear which one gets // called first - this.roomThreadsMap.set(room, new ThreadsRoomNotificationState(room)); + const threadState = new ThreadsRoomNotificationState(room); + this.roomThreadsMap.set(room, threadState); + this.roomMap.set(room, new RoomNotificationState(room, threadState)); } return this.roomMap.get(room); } diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 5a86dd38c5f..7b107829250 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"); } diff --git a/src/utils/Reply.ts b/src/utils/Reply.ts index 43144a34a18..87cec553737 100644 --- a/src/utils/Reply.ts +++ b/src/utils/Reply.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { IContent, IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; import sanitizeHtml from "sanitize-html"; import escapeHtml from "escape-html"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; @@ -22,7 +22,6 @@ import { MsgType } from "matrix-js-sdk/src/@types/event"; import { PERMITTED_URL_SCHEMES } from "../HtmlUtils"; import { makeUserPermalink, RoomPermalinkCreator } from "./permalinks/Permalinks"; -import { RecursivePartial } from "../@types/common"; import SettingsStore from "../settings/SettingsStore"; export function getParentEventId(ev?: MatrixEvent): string | undefined { @@ -144,30 +143,17 @@ export function getNestedReplyText( return { body, html }; } -export function makeReplyMixIn(ev?: MatrixEvent): RecursivePartial { +export function makeReplyMixIn(ev?: MatrixEvent): IEventRelation { if (!ev) return {}; - const mixin: RecursivePartial = { - 'm.relates_to': { - 'm.in_reply_to': { - 'event_id': ev.getId(), - }, + const mixin: IEventRelation = { + 'm.in_reply_to': { + 'event_id': ev.getId(), }, }; - /** - * If the event replied is part of a thread - * Add the `m.thread` relation so that clients - * that know how to handle that relation will - * be able to render them more accurately - */ - if (ev.isThreadRelation || ev.isThreadRoot) { - mixin['m.relates_to'] = { - ...mixin['m.relates_to'], - is_falling_back: false, - rel_type: THREAD_RELATION_TYPE.name, - event_id: ev.threadRootId, - }; + if (SettingsStore.getValue("feature_thread") && ev.threadRootId) { + mixin.is_falling_back = false; } return mixin; @@ -206,12 +192,13 @@ export function addReplyToMessageContent( includeLegacyFallback: true, }, ): void { - const replyContent = makeReplyMixIn(replyToEvent); - Object.assign(content, replyContent); + content["m.relates_to"] = { + ...(content["m.relates_to"] || {}), + ...makeReplyMixIn(replyToEvent), + }; if (opts.includeLegacyFallback) { - // Part of Replies fallback support - prepend the text we're sending - // with the text we're replying to + // Part of Replies fallback support - prepend the text we're sending with the text we're replying to const nestedReply = getNestedReplyText(replyToEvent, opts.permalinkCreator); if (nestedReply) { if (content.formatted_body) { diff --git a/src/utils/beacon/duration.ts b/src/utils/beacon/duration.ts index 30d5eac4852..b8338a8536f 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 7ad06fcf12d..29e52e233e9 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/RoomLiveShareWarning-test.tsx b/test/components/views/beacon/RoomLiveShareWarning-test.tsx index d549e9a51ee..97cc953d520 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__/LeftPanelLiveShareWarning-test.tsx.snap b/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap index 39c8cc6b6a7..bd9f943b35e 100644 --- a/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/LeftPanelLiveShareWarning-test.tsx.snap @@ -4,23 +4,73 @@ exports[` when user has live location monitor rende -
-
+ className="mx_AccessibleButton mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__minimized" + onClick={[Function]} + onKeyDown={[Function]} + onKeyUp={[Function]} + role="button" + tabIndex={0} + title="You are sharing your live location" + > +
+
+ `; exports[` when user has live location monitor renders correctly when not minimized 1`] = ` -
- You are sharing your live location -
+
+ You are sharing your live location +
+ +
+`; + +exports[` when user has live location monitor renders wire error 1`] = ` + + +
+ An error occured whilst sharing your live location +
+
`; diff --git a/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap index 0f765124456..8ae076a2a14 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 d40708109e5..57e66d636b2 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,141 @@ describe('OwnBeaconStore', () => { }); }); + describe('when publishing position fails', () => { + beforeEach(() => { + geolocation.watchPosition.mockImplementation( + watchPositionMockImplementation([0, 1000, 3000, 3000, 3000]), + ); + + // eat expected console error logs + jest.spyOn(logger, 'error').mockImplementation(() => { }); + }); + + // we need to advance time and then flush promises + // individually for each call to sendEvent + // otherwise the sendEvent doesn't reject/resolve and update state + // before the next call + // advance and flush every 1000ms + // until given ms is 'elapsed' + const advanceAndFlushPromises = async (timeMs: number) => { + while (timeMs > 0) { + jest.advanceTimersByTime(1000); + await flushPromisesWithFakeTimers(); + timeMs -= 1000; + } + }; + + it('continues publishing positions after one publish error', async () => { + // fail to send first event, then succeed + mockClient.sendEvent.mockRejectedValueOnce(new Error('oups')).mockResolvedValue({ event_id: '1' }); + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + await advanceAndFlushPromises(50000); + + // called for each position from watchPosition + expect(mockClient.sendEvent).toHaveBeenCalledTimes(5); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + expect(store.hasWireErrors()).toBe(false); + }); + + it('continues publishing positions when a beacon fails intermittently', async () => { + // every second event rejects + // meaning this beacon has more errors than the threshold + // but they are not consecutive + mockClient.sendEvent + .mockRejectedValueOnce(new Error('oups')) + .mockResolvedValueOnce({ event_id: '1' }) + .mockRejectedValueOnce(new Error('oups')) + .mockResolvedValueOnce({ event_id: '1' }) + .mockRejectedValueOnce(new Error('oups')); + + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + await advanceAndFlushPromises(50000); + + // called for each position from watchPosition + expect(mockClient.sendEvent).toHaveBeenCalledTimes(5); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + expect(store.hasWireErrors()).toBe(false); + expect(emitSpy).not.toHaveBeenCalledWith( + OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(), + ); + }); + + it('stops publishing positions when a beacon fails consistently', async () => { + // always fails to send events + mockClient.sendEvent.mockRejectedValue(new Error('oups')); + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + // 5 positions from watchPosition in this period + await advanceAndFlushPromises(50000); + + // only two allowed failures + expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(true); + expect(store.hasWireErrors()).toBe(true); + expect(emitSpy).toHaveBeenCalledWith( + OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(), + ); + }); + + it('restarts publishing a beacon after resetting wire error', async () => { + // always fails to send events + mockClient.sendEvent.mockRejectedValue(new Error('oups')); + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + // 3 positions from watchPosition in this period + await advanceAndFlushPromises(4000); + + // only two allowed failures + expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(true); + expect(store.hasWireErrors()).toBe(true); + expect(store.hasWireErrors(room1Id)).toBe(true); + expect(emitSpy).toHaveBeenCalledWith( + OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(), + ); + + // reset emitSpy mock counts to asser on wireError again + emitSpy.mockClear(); + store.resetWireError(alicesRoom1BeaconInfo.getType()); + + expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false); + + // 2 more positions from watchPosition in this period + await advanceAndFlushPromises(10000); + + // 2 from before, 2 new ones + expect(mockClient.sendEvent).toHaveBeenCalledTimes(4); + expect(emitSpy).toHaveBeenCalledWith( + OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(), + ); + }); + }); + it('publishes subsequent positions', async () => { // modern fake timers + debounce + promises are not friends // just testing that positions are published diff --git a/test/utils/beacon/duration-test.ts b/test/utils/beacon/duration-test.ts index 822860097b1..e8a0d36c633 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, + ]); + }); + }); });