Merge branch 'develop' into travis/remove-skinning

This commit is contained in:
Travis Ralston 2022-03-31 19:25:43 -06:00
commit 97efdf7094
54 changed files with 1559 additions and 431 deletions

View file

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

View file

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

View file

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

View file

@ -28,3 +28,8 @@ limitations under the License.
// colors icon
color: white;
}
.mx_StyledLiveBeaconIcon.mx_StyledLiveBeaconIcon_error {
background-color: $alert;
border-color: $alert;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -49,6 +49,7 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog";
import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog";
import { createThumbnail } from "./utils/image-media";
import { attachRelation } from "./components/views/rooms/SendMessageComposer";
// scraped out of a macOS hidpi (5660ppm) screenshot png
// 5669 px (x-axis) , 5669 px (y-axis) , per metre
@ -147,15 +148,20 @@ async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imag
const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
const imageInfo = result.info;
// we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
if (
imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already
(sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original
sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT))
) {
delete imageInfo["thumbnail_info"];
return imageInfo;
// For lesser supported image types, always include the thumbnail even if it is larger
if (!["image/avif", "image/webp"].includes(imageFile.type)) {
// we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
if (
// image is small enough already
imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL ||
// thumbnail is not sufficiently smaller than original
(sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE &&
sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT))
) {
delete imageInfo["thumbnail_info"];
return imageInfo;
}
}
const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail);
@ -474,10 +480,7 @@ export default class ContentMessages {
msgtype: "", // set later
};
if (relation) {
content["m.relates_to"] = relation;
}
attachRelation(content, relation);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, {
includeLegacyFallback: false,

20
src/Editing.ts Normal file
View file

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

View file

@ -126,7 +126,7 @@ export function showAnyInviteErrors(
// user. This usually means that no other users were attempted, making it
// pointless for us to list who failed exactly.
Modal.createTrackedDialog('Failed to invite users to the room', '', ErrorDialog, {
title: _t("Failed to invite users to the room:", { roomName: room.name }),
title: _t("Failed to invite users to %(roomName)s", { roomName: room.name }),
description: inviter.getErrorText(failedUsers[0]),
});
return false;

View file

@ -21,6 +21,8 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClientPeg } from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent';
import { haveRendererForEvent } from "./events/EventTileFactory";
import SettingsStore from "./settings/SettingsStore";
import { RoomNotificationStateStore } from "./stores/notifications/RoomNotificationStateStore";
/**
* Returns true if this event arriving in a room should affect the room's
@ -57,14 +59,21 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
// despite the name of the method :((
const readUpToId = room.getEventReadUpTo(myUserId);
// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/element-web/issues/3263
// https://github.com/vector-im/element-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/element-web/issues/3363
if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
return false;
if (!SettingsStore.getValue("feature_thread")) {
// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/element-web/issues/3263
// https://github.com/vector-im/element-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/element-web/issues/3363
if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
return false;
}
} else {
const threadState = RoomNotificationStateStore.instance.getThreadsRoomState(room);
if (threadState.color > 0) {
return true;
}
}
// if the read receipt relates to an event is that part of a thread

View file

@ -53,6 +53,7 @@ import { Action } from '../../dispatcher/actions';
import { getEventDisplayInfo } from "../../utils/EventRenderingUtils";
import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker";
import { haveRendererForEvent } from "../../events/EventTileFactory";
import { editorRoomKey } from "../../Editing";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
@ -306,9 +307,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
const pendingEditItem = this.pendingEditItem;
if (!this.props.editState && this.props.room && pendingEditItem) {
const event = this.props.room.findEventById(pendingEditItem);
defaultDispatcher.dispatch({
action: Action.EditEvent,
event: this.props.room.findEventById(pendingEditItem),
event: !event?.isRedacted() ? event : null,
timelineRenderingType: this.context.timelineRenderingType,
});
}
@ -612,13 +614,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
if (!this.props.room) {
return undefined;
}
try {
return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}_${this.context.timelineRenderingType}`);
return localStorage.getItem(editorRoomKey(this.props.room.roomId, this.context.timelineRenderingType));
} catch (err) {
logger.error(err);
return undefined;
}
}
private getEventTiles(): ReactNode[] {
let i;
@ -721,10 +725,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
): 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) {

View file

@ -364,7 +364,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.checkWidgets(this.state.room);
};
private checkWidgets = (room) => {
private checkWidgets = (room: Room): void => {
this.setState({
hasPinnedWidgets: WidgetLayoutStore.instance.hasPinnedWidgets(room),
mainSplitContentType: this.getMainSplitContentType(room),
@ -372,7 +372,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
});
};
private getMainSplitContentType = (room) => {
private getMainSplitContentType = (room: Room) => {
if (SettingsStore.getValue("feature_voice_rooms") && room.isCallRoom()) {
return MainSplitContentType.Video;
}
@ -1981,11 +1981,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
);
let messageComposer; let searchInfo;
const canSpeak = (
const showComposer = (
// joined and not showing search results
myMembership === 'join' && !this.state.searchResults
);
if (canSpeak) {
if (showComposer) {
messageComposer =
<MessageComposer
room={this.state.room}
@ -2101,10 +2101,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
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 = <>
<Measured
sensor={this.roomViewBody.current}
@ -2124,16 +2126,21 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
</>;
break;
case MainSplitContentType.MaximisedWidget:
mainSplitBody = <AppsDrawer
room={this.state.room}
userId={this.context.credentials.userId}
resizeNotifier={this.props.resizeNotifier}
showApps={true}
/>;
mainSplitContentClassName = "mx_MainSplit_maximisedWidget";
mainSplitBody = <>
<AppsDrawer
room={this.state.room}
userId={this.context.credentials.userId}
resizeNotifier={this.props.resizeNotifier}
showApps={true}
/>
{ previewBar }
</>;
break;
case MainSplitContentType.Video: {
const app = getVoiceChannel(this.state.room.roomId);
if (!app) break;
mainSplitContentClassName = "mx_MainSplit_video";
mainSplitBody = <AppTile
app={app}
room={this.state.room}
@ -2145,6 +2152,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
/>;
}
}
const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName);
let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline];
let onAppsClick = this.onAppsClick;
let onForgetClick = this.onForgetClick;
@ -2160,6 +2169,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
onForgetClick = null;
onSearchClick = null;
}
return (
<RoomContext.Provider value={this.state}>
<main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
@ -2181,7 +2191,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons}
/>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
<div className="mx_RoomView_body" ref={this.roomViewBody} data-layout={this.state.layout}>
<div className={mainSplitContentClasses} ref={this.roomViewBody} data-layout={this.state.layout}>
{ mainSplitBody }
</div>
</MainSplit>

View file

@ -50,6 +50,7 @@ import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import Measured from '../views/elements/Measured';
import PosthogTrackers from "../../PosthogTrackers";
import { ButtonEvent } from "../views/elements/AccessibleButton";
import RoomViewStore from '../../stores/RoomViewStore';
interface IProps {
room: Room;
@ -104,9 +105,19 @@ export default class ThreadView extends React.Component<IProps, IState> {
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<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
metricsTrigger: undefined, // room doesn't change
});
}
}
public componentDidUpdate(prevProps) {
@ -204,7 +215,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
}
};
private onScroll = (): void => {
private resetHighlightedEvent = (): void => {
if (this.props.initialEvent && this.props.isInitialEventHighlighted) {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
@ -361,7 +372,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
editState={this.state.editState}
eventId={this.props.initialEvent?.getId()}
highlightedEventId={highlightedEventId}
onUserScroll={this.onScroll}
onUserScroll={this.resetHighlightedEvent}
onPaginationRequest={this.onPaginationRequest}
/>
</div> }

View file

@ -1,5 +1,5 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, ReactNode, RefObject } from "react";
import React, { ReactNode } from "react";
import PlayPauseButton from "./PlayPauseButton";
import { formatBytes } from "../../../utils/FormattingUtils";
@ -23,40 +23,8 @@ import { _t } from "../../../languageHandler";
import SeekBar from "./SeekBar";
import PlaybackClock from "./PlaybackClock";
import AudioPlayerBase from "./AudioPlayerBase";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
export default class AudioPlayer extends AudioPlayerBase {
private playPauseRef: RefObject<PlayPauseButton> = createRef();
private seekRef: RefObject<SeekBar> = 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;

View file

@ -1,5 +1,5 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,14 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from "react";
import React, { createRef, ReactNode, RefObject } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { Playback, PlaybackState } from "../../../audio/Playback";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { _t } from "../../../languageHandler";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import SeekBar from "./SeekBar";
import PlayPauseButton from "./PlayPauseButton";
interface IProps {
export interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
@ -34,8 +38,11 @@ interface IState {
error?: boolean;
}
export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
export default abstract class AudioPlayerBase<T extends IProps = IProps> extends React.PureComponent<T, IState> {
protected seekRef: RefObject<SeekBar> = createRef();
protected playPauseRef: RefObject<PlayPauseButton> = createRef();
constructor(props: T) {
super(props);
// Playback instances can be reused in the composer
@ -54,6 +61,33 @@ export default abstract class AudioPlayerBase extends React.PureComponent<IProps
});
}
protected 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();
}
};
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};

View file

@ -1,5 +1,5 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,28 +18,49 @@ import React, { ReactNode } from "react";
import PlayPauseButton from "./PlayPauseButton";
import PlaybackClock from "./PlaybackClock";
import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase";
import SeekBar from "./SeekBar";
import PlaybackWaveform from "./PlaybackWaveform";
import AudioPlayerBase from "./AudioPlayerBase";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
export default class RecordingPlayback extends AudioPlayerBase {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
interface IProps extends IAudioPlayerBaseProps {
/**
* When true, use a waveform instead of a seek bar
*/
withWaveform?: boolean;
}
private get isWaveformable(): boolean {
return this.context.timelineRenderingType !== TimelineRenderingType.Notification
&& this.context.timelineRenderingType !== TimelineRenderingType.File
&& this.context.timelineRenderingType !== TimelineRenderingType.Pinned;
export default class RecordingPlayback extends AudioPlayerBase<IProps> {
// This component is rendered in two ways: the composer and timeline. They have different
// rendering properties (specifically the difference of a waveform or not).
private renderWaveformLook(): ReactNode {
return <>
<PlaybackClock playback={this.props.playback} />
<PlaybackWaveform playback={this.props.playback} />
</>;
}
private renderSeekableLook(): ReactNode {
return <>
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} />
</>;
}
protected renderComponent(): ReactNode {
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
return (
<div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
<PlaybackClock playback={this.props.playback} />
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
<div className="mx_MediaBody mx_VoiceMessagePrimaryContainer" onKeyDown={this.onKeyDown}>
<PlayPauseButton
playback={this.props.playback}
playbackPhase={this.state.playbackPhase}
ref={this.playPauseRef}
/>
{ this.props.withWaveform ? this.renderWaveformLook() : this.renderSeekableLook() }
</div>
);
}

View file

@ -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<Props> = ({ isMinimized }) => {
const isMonitoringLiveLocation = useEventEmitterState(
OwnBeaconStore.instance,
@ -33,18 +53,48 @@ const LeftPanelLiveShareWarning: React.FC<Props> = ({ 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 <div
const relevantBeaconRoomId = chooseBestBeaconRoomId(liveBeaconIds, beaconIdsWithWireError);
const onWarningClick = relevantBeaconRoomId ? () => {
dispatcher.dispatch<ViewRoomPayload>({
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 <AccessibleButton
className={classNames('mx_LeftPanelLiveShareWarning', {
'mx_LeftPanelLiveShareWarning__minimized': isMinimized,
'mx_LeftPanelLiveShareWarning__error': hasWireErrors,
})}
title={isMinimized ? _t('You are sharing your live location') : undefined}
title={isMinimized ? label : undefined}
onClick={onWarningClick}
>
{ isMinimized ? <LiveLocationIcon height={10} /> : _t('You are sharing your live location') }
</div>;
{ isMinimized ? <LiveLocationIcon height={10} /> : label }
</AccessibleButton>;
};
export default LeftPanelLiveShareWarning;

View file

@ -18,19 +18,16 @@ import React, { useCallback, useEffect, useState } from 'react';
import classNames from 'classnames';
import { Room, Beacon } from 'matrix-js-sdk/src/matrix';
import { formatDuration } from '../../../DateUtils';
import { _t } from '../../../languageHandler';
import { useEventEmitterState } from '../../../hooks/useEventEmitter';
import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore';
import AccessibleButton from '../elements/AccessibleButton';
import StyledLiveBeaconIcon from './StyledLiveBeaconIcon';
import { formatDuration } from '../../../DateUtils';
import { getBeaconMsUntilExpiry, sortBeaconsByLatestExpiry } from '../../../utils/beacon';
import Spinner from '../elements/Spinner';
import { useInterval } from '../../../hooks/useTimeout';
interface Props {
roomId: Room['roomId'];
}
import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore';
import { getBeaconMsUntilExpiry, sortBeaconsByLatestExpiry } from '../../../utils/beacon';
import AccessibleButton from '../elements/AccessibleButton';
import Spinner from '../elements/Spinner';
import StyledLiveBeaconIcon from './StyledLiveBeaconIcon';
import { Icon as CloseIcon } from '../../../../res/img/image-view/close.svg';
const MINUTE_MS = 60000;
const HOUR_MS = MINUTE_MS * 60;
@ -72,33 +69,28 @@ const useMsRemaining = (beacon: Beacon): number => {
type LiveBeaconsState = {
beacon?: Beacon;
onStopSharing?: () => void;
onResetWireError?: () => void;
stoppingInProgress?: boolean;
hasStopSharingError?: boolean;
hasWireError?: boolean;
};
const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => {
const useLiveBeacons = (liveBeaconIds: string[], roomId: string): LiveBeaconsState => {
const [stoppingInProgress, setStoppingInProgress] = useState(false);
const [error, setError] = useState<Error>();
// do we have an active geolocation.watchPosition
const isMonitoringLiveLocation = useEventEmitterState(
const hasWireError = useEventEmitterState(
OwnBeaconStore.instance,
OwnBeaconStoreEvent.MonitoringLivePosition,
() => OwnBeaconStore.instance.isMonitoringLiveLocation,
);
const liveBeaconIds = useEventEmitterState(
OwnBeaconStore.instance,
OwnBeaconStoreEvent.LivenessChange,
() => OwnBeaconStore.instance.getLiveBeaconIds(roomId),
OwnBeaconStoreEvent.WireError,
() =>
OwnBeaconStore.instance.hasWireErrors(roomId),
);
// reset stopping in progress on change in live ids
useEffect(() => {
setStoppingInProgress(false);
setError(undefined);
}, [liveBeaconIds]);
if (!isMonitoringLiveLocation || !liveBeaconIds?.length) {
return {};
}
// select the beacon with latest expiry to display expiry time
const beacon = liveBeaconIds.map(beaconId => OwnBeaconStore.instance.getBeaconById(beaconId))
.sort(sortBeaconsByLatestExpiry)
@ -112,11 +104,23 @@ const useLiveBeacons = (roomId: Room['roomId']): LiveBeaconsState => {
// only clear loading in case of error
// to avoid flash of not-loading state
// after beacons have been stopped but we wait for sync
setError(error);
setStoppingInProgress(false);
}
};
return { onStopSharing, beacon, stoppingInProgress };
const onResetWireError = () => {
liveBeaconIds.map(beaconId => OwnBeaconStore.instance.resetWireError(beaconId));
};
return {
onStopSharing,
onResetWireError,
beacon,
stoppingInProgress,
hasWireError,
hasStopSharingError: !!error,
};
};
const LiveTimeRemaining: React.FC<{ beacon: Beacon }> = ({ beacon }) => {
@ -131,39 +135,103 @@ const LiveTimeRemaining: React.FC<{ beacon: Beacon }> = ({ beacon }) => {
>{ liveTimeRemaining }</span>;
};
const RoomLiveShareWarning: React.FC<Props> = ({ 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<RoomLiveShareWarningInnerProps> = ({ liveBeaconIds, roomId }) => {
const {
onStopSharing,
onResetWireError,
beacon,
stoppingInProgress,
} = useLiveBeacons(roomId);
hasStopSharingError,
hasWireError,
} = useLiveBeacons(liveBeaconIds, roomId);
if (!beacon) {
return null;
}
const hasError = hasStopSharingError || hasWireError;
const onButtonClick = () => {
if (hasWireError) {
onResetWireError();
} else {
onStopSharing();
}
};
return <div
className={classNames('mx_RoomLiveShareWarning')}
>
<StyledLiveBeaconIcon className="mx_RoomLiveShareWarning_icon" />
<StyledLiveBeaconIcon className="mx_RoomLiveShareWarning_icon" withError={hasError} />
<span className="mx_RoomLiveShareWarning_label">
{ _t('You are sharing your live location') }
{ getLabel(hasWireError, hasStopSharingError) }
</span>
{ stoppingInProgress ?
<span className='mx_RoomLiveShareWarning_spinner'><Spinner h={16} w={16} /></span> :
<LiveTimeRemaining beacon={beacon} />
{ stoppingInProgress &&
<span className='mx_RoomLiveShareWarning_spinner'><Spinner h={16} w={16} /></span>
}
{ !stoppingInProgress && !hasError && <LiveTimeRemaining beacon={beacon} /> }
<AccessibleButton
data-test-id='room-live-share-stop-sharing'
onClick={onStopSharing}
data-test-id='room-live-share-primary-button'
onClick={onButtonClick}
kind='danger'
element='button'
disabled={stoppingInProgress}
>
{ _t('Stop sharing') }
{ hasError ? _t('Retry') : _t('Stop sharing') }
</AccessibleButton>
{ hasWireError && <AccessibleButton
data-test-id='room-live-share-wire-error-close-button'
title={_t('Stop sharing and close')}
element='button'
className='mx_RoomLiveShareWarning_closeButton'
onClick={onStopSharing}
>
<CloseIcon className='mx_RoomLiveShareWarning_closeButtonIcon' />
</AccessibleButton> }
</div>;
};
interface Props {
roomId: Room['roomId'];
}
const RoomLiveShareWarning: React.FC<Props> = ({ 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 <RoomLiveShareWarningInner liveBeaconIds={liveBeaconIds} roomId={roomId} />;
};
export default RoomLiveShareWarning;

View file

@ -19,10 +19,14 @@ import classNames from 'classnames';
import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg';
const StyledLiveBeaconIcon: React.FC<React.SVGProps<SVGSVGElement>> = ({ className, ...props }) =>
interface Props extends React.SVGProps<SVGSVGElement> {
// use error styling when true
withError?: boolean;
}
const StyledLiveBeaconIcon: React.FC<Props> = ({ className, withError, ...props }) =>
<LiveLocationIcon
{...props}
className={classNames('mx_StyledLiveBeaconIcon', className)}
className={classNames('mx_StyledLiveBeaconIcon', className, { 'mx_StyledLiveBeaconIcon_error': withError })}
/>;
export default StyledLiveBeaconIcon;

View file

@ -219,6 +219,9 @@ export default class TimelineCard extends React.Component<IProps, IState> {
const isUploading = ContentMessages.sharedInstance().getCurrentUploads(this.props.composerRelation).length > 0;
const myMembership = this.props.room.getMyMembership();
const showComposer = myMembership === "join";
return (
<RoomContext.Provider value={{
...this.context,
@ -268,15 +271,17 @@ export default class TimelineCard extends React.Component<IProps, IState> {
<UploadBar room={this.props.room} relation={this.props.composerRelation} />
) }
<MessageComposer
room={this.props.room}
relation={this.props.composerRelation}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
{ showComposer && (
<MessageComposer
room={this.props.room}
relation={this.props.composerRelation}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
) }
</BaseCard>
</RoomContext.Provider>
);

View file

@ -430,8 +430,7 @@ const UserOptionsSection: React.FC<{
const roomId = member && member.roomId ? member.roomId : RoomViewStore.instance.getRoomId();
const onInviteUserButton = async (ev: ButtonEvent) => {
try {
// We use a MultiInviter to re-use the invite logic, even though
// we're only inviting one user.
// We use a MultiInviter to re-use the invite logic, even though we're only inviting one user.
const inviter = new MultiInviter(roomId);
await inviter.invite([member.userId]).then(() => {
if (inviter.getCompletionState(member.userId) !== "invited") {

View file

@ -46,6 +46,7 @@ import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
import { editorRoomKey, editorStateKey } from "../../../Editing";
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
@ -222,11 +223,11 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
}
private get editorRoomKey(): string {
return `mx_edit_room_${this.getRoom().roomId}_${this.context.timelineRenderingType}`;
return editorRoomKey(this.props.editState.getEvent().getRoomId(), this.context.timelineRenderingType);
}
private get editorStateKey(): string {
return `mx_edit_state_${this.props.editState.getEvent().getId()}`;
return editorStateKey(this.props.editState.getEvent().getId());
}
private get events(): MatrixEvent[] {
@ -314,6 +315,9 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
this.cancelPreviousPendingEdit();
createRedactEventDialog({
mxEvent: editedEvent,
onCloseDialog: () => {
this.cancelEdit();
},
});
return;
}

View file

@ -225,17 +225,6 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
.getStateEvents(EventType.RoomJoinRules, "")?.getContent<IJoinRuleEventContent>().join_rule;
}
private roomName(atStart = false): string {
const name = this.props.room ? this.props.room.name : this.props.roomAlias;
if (name) {
return name;
} else if (atStart) {
return _t("This room");
} else {
return _t("this room");
}
}
private getMyMember(): RoomMember {
return this.props.room?.getMember(MatrixClientPeg.get().getUserId());
}
@ -287,6 +276,8 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
render() {
const brand = SdkConfig.get().brand;
const roomName = this.props.room?.name ?? this.props.roomAlias ?? "";
const isSpace = this.props.room?.isSpaceRoom() ?? this.props.oobData?.roomType === RoomType.Space;
let showSpinner = false;
let title;
@ -302,7 +293,12 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
const messageCase = this.getMessageCase();
switch (messageCase) {
case MessageCase.Joining: {
title = this.props.oobData?.roomType === RoomType.Space ? _t("Joining space …") : _t("Joining room …");
if (this.props.oobData?.roomType || isSpace) {
title = isSpace ? _t("Joining space …") : _t("Joining room …");
} else {
title = _t("Joining …");
}
showSpinner = true;
break;
}
@ -328,7 +324,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
footer = (
<div>
<Spinner w={20} h={20} />
{ _t("Loading room preview") }
{ _t("Loading preview") }
</div>
);
}
@ -336,37 +332,56 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
}
case MessageCase.Kicked: {
const { memberName, reason } = this.getKickOrBanInfo();
title = _t("You were removed from %(roomName)s by %(memberName)s",
{ memberName, roomName: this.roomName() });
if (roomName) {
title = _t("You were removed from %(roomName)s by %(memberName)s",
{ memberName, roomName });
} else {
title = _t("You were removed by %(memberName)s", { memberName });
}
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null;
if (this.joinRule() === "invite") {
primaryActionLabel = _t("Forget this room");
primaryActionHandler = this.props.onForgetClick;
if (isSpace) {
primaryActionLabel = _t("Forget this space");
} else {
primaryActionLabel = _t("Forget this room");
}
primaryActionHandler = this.props.onForgetClick;
if (this.joinRule() !== JoinRule.Invite) {
secondaryActionLabel = primaryActionLabel;
secondaryActionHandler = primaryActionHandler;
primaryActionLabel = _t("Re-join");
primaryActionHandler = this.props.onJoinClick;
secondaryActionLabel = _t("Forget this room");
secondaryActionHandler = this.props.onForgetClick;
}
break;
}
case MessageCase.Banned: {
const { memberName, reason } = this.getKickOrBanInfo();
title = _t("You were banned from %(roomName)s by %(memberName)s",
{ memberName, roomName: this.roomName() });
if (roomName) {
title = _t("You were banned from %(roomName)s by %(memberName)s", { memberName, roomName });
} else {
title = _t("You were banned by %(memberName)s", { memberName });
}
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null;
primaryActionLabel = _t("Forget this room");
if (isSpace) {
primaryActionLabel = _t("Forget this space");
} else {
primaryActionLabel = _t("Forget this room");
}
primaryActionHandler = this.props.onForgetClick;
break;
}
case MessageCase.OtherThreePIDError: {
title = _t("Something went wrong with your invite to %(roomName)s",
{ roomName: this.roomName() });
if (roomName) {
title = _t("Something went wrong with your invite to %(roomName)s", { roomName });
} else {
title = _t("Something went wrong with your invite.");
}
const joinRule = this.joinRule();
const errCodeMessage = _t(
"An error (%(errcode)s) was returned while trying to validate your " +
"invite. You could try to pass this information on to a room admin.",
"invite. You could try to pass this information on to the person who invited you.",
{ errcode: this.state.threePidFetchError.errcode || _t("unknown error code") },
);
switch (joinRule) {
@ -379,7 +394,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
primaryActionHandler = this.props.onJoinClick;
break;
case "public":
subTitle = _t("You can still join it because this is a public room.");
subTitle = _t("You can still join here.");
primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick;
break;
@ -392,14 +407,22 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
break;
}
case MessageCase.InvitedEmailNotFoundInAccount: {
title = _t(
"This invite to %(roomName)s was sent to %(email)s which is not " +
"associated with your account",
{
roomName: this.roomName(),
email: this.props.invitedEmail,
},
);
if (roomName) {
title = _t(
"This invite to %(roomName)s was sent to %(email)s which is not " +
"associated with your account",
{
roomName,
email: this.props.invitedEmail,
},
);
} else {
title = _t(
"This invite was sent to %(email)s which is not associated with your account",
{ email: this.props.invitedEmail },
);
}
subTitle = _t(
"Link this email with your account in Settings to receive invites " +
"directly in %(brand)s.",
@ -410,13 +433,18 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
break;
}
case MessageCase.InvitedEmailNoIdentityServer: {
title = _t(
"This invite to %(roomName)s was sent to %(email)s",
{
roomName: this.roomName(),
email: this.props.invitedEmail,
},
);
if (roomName) {
title = _t(
"This invite to %(roomName)s was sent to %(email)s",
{
roomName,
email: this.props.invitedEmail,
},
);
} else {
title = _t("This invite was sent to %(email)s", { email: this.props.invitedEmail });
}
subTitle = _t(
"Use an identity server in Settings to receive invites directly in %(brand)s.",
{ brand },
@ -426,13 +454,18 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
break;
}
case MessageCase.InvitedEmailMismatch: {
title = _t(
"This invite to %(roomName)s was sent to %(email)s",
{
roomName: this.roomName(),
email: this.props.invitedEmail,
},
);
if (roomName) {
title = _t(
"This invite to %(roomName)s was sent to %(email)s",
{
roomName,
email: this.props.invitedEmail,
},
);
} else {
title = _t("This invite was sent to %(email)s", { email: this.props.invitedEmail });
}
subTitle = _t(
"Share this email in Settings to receive invites directly in %(brand)s.",
{ brand },
@ -458,16 +491,14 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
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("<userName/> 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("<userName/> invited you", {}, { userName: () => inviterElement }),
@ -500,27 +531,35 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
}
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 " +
"<issueLink>submit a bug report</issueLink>.",
{ errcode: this.props.error.errcode },

View file

@ -59,14 +59,12 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
import { addReplyToMessageContent } from '../../../utils/Reply';
export function attachRelation(
content: IContent,
relation?: IEventRelation,
): void {
// Merges favouring the given relation
export function attachRelation(content: IContent, relation?: IEventRelation): void {
if (relation) {
content['m.relates_to'] = {
...relation, // the composer can have a default
...content['m.relates_to'],
...(content['m.relates_to'] || {}),
...relation,
};
}
}
@ -99,6 +97,7 @@ export function createMessageContent(
content.formatted_body = formattedBody;
}
attachRelation(content, relation);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator,
@ -106,13 +105,6 @@ export function createMessageContent(
});
}
if (relation) {
content['m.relates_to'] = {
...relation,
...content['m.relates_to'],
};
}
return content;
}

View file

@ -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 <>
<MemberAvatar
member={sender}
member={lastReply.sender}
fallbackUserId={lastReply.getSender()}
width={24}
height={24}
className="mx_ThreadInfo_avatar"
/>
{ showDisplayname && <div className="mx_ThreadInfo_sender">
{ sender?.name ?? lastReply.getSender() }
{ lastReply.sender?.name ?? lastReply.getSender() }
</div> }
<div className="mx_ThreadInfo_content">
<span className="mx_ThreadInfo_message-preview">

View file

@ -231,7 +231,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
if (this.state.recordingPhase !== RecordingState.Started) {
return <RecordingPlayback playback={this.state.recorder.getPlayback()} />;
return <RecordingPlayback playback={this.state.recorder.getPlayback()} withWaveform={true} />;
}
// only other UI is the recording-in-progress UI

View file

@ -97,6 +97,7 @@ export default class AdvancedRoomSettingsTab extends React.Component<IProps, ISt
render() {
const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId);
const isSpace = room.isSpaceRoom();
let unfederatableSection;
const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, '');
@ -120,7 +121,9 @@ export default class AdvancedRoomSettingsTab extends React.Component<IProps, ISt
) }
</p>
<AccessibleButton onClick={this.upgradeRoom} kind='primary'>
{ _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") }
</AccessibleButton>
</div>
);
@ -128,12 +131,16 @@ export default class AdvancedRoomSettingsTab extends React.Component<IProps, ISt
let oldRoomLink;
if (this.state.oldRoomId) {
let name = _t("this room");
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
if (room && room.name) name = room.name;
let copy: string;
if (isSpace) {
copy = _t("View older version of %(spaceName)s.", { spaceName: room.name });
} else {
copy = _t("View older messages in %(roomName)s.", { roomName: room.name });
}
oldRoomLink = (
<AccessibleButton element='a' onClick={this.onOldRoomClicked}>
{ _t("View older messages in %(roomName)s.", { roomName: name }) }
{ copy }
</AccessibleButton>
);
}

View file

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

View file

@ -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 <RoomName/>": "We sent the others, but the below people couldn't be invited to <RoomName/>",
"Some invites couldn't be sent": "Some invites couldn't be sent",
"You need to be logged in.": "You need to be logged in.",
@ -694,12 +694,16 @@
"Not a valid %(brand)s keyfile": "Not a valid %(brand)s keyfile",
"Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?",
"Unrecognised address": "Unrecognised address",
"You do not have permission to invite people to this space.": "You do not have permission to invite people to this space.",
"You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.",
"User %(userId)s is already invited to the room": "User %(userId)s is already invited to the room",
"User %(userId)s is already in the room": "User %(userId)s is already in the room",
"User %(user_id)s does not exist": "User %(user_id)s does not exist",
"User %(user_id)s may or may not exist": "User %(user_id)s may or may not exist",
"User is already invited to the space": "User is already invited to the space",
"User is already invited to the room": "User is already invited to the room",
"User is already in the space": "User is already in the space",
"User is already in the room": "User is already in the room",
"User does not exist": "User does not exist",
"User may or may not exist": "User may or may not exist",
"The user must be unbanned before they can be invited.": "The user must be unbanned before they can be invited.",
"The user's homeserver does not support the version of the space.": "The user's homeserver does not support the version of the space.",
"The user's homeserver does not support the version of the room.": "The user's homeserver does not support the version of the room.",
"Unknown server error": "Unknown server error",
"Use a few words, avoid common phrases": "Use a few words, avoid common phrases",
@ -815,12 +819,12 @@
"Update %(brand)s": "Update %(brand)s",
"New version of %(brand)s is available": "New version of %(brand)s is available",
"Guest": "Guest",
"There was an error joining the room": "There was an error joining the room",
"Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.",
"There was an error joining.": "There was an error joining.",
"Sorry, your homeserver is too old to participate here.": "Sorry, your homeserver is too old to participate here.",
"Please contact your homeserver administrator.": "Please contact your homeserver administrator.",
"The person who invited you already left the room.": "The person who invited you already left the room.",
"The person who invited you already left the room, or their server is offline.": "The person who invited you already left the room, or their server is offline.",
"Failed to join room": "Failed to join room",
"The person who invited you has already left.": "The person who invited you has already left.",
"The person who invited you has already left, or their server is offline.": "The person who invited you has already left, or their server is offline.",
"Failed to join": "Failed to join",
"All rooms": "All rooms",
"Home": "Home",
"Favourites": "Favourites",
@ -889,7 +893,7 @@
"Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
"Don't send read receipts": "Don't send read receipts",
"Location sharing - pin drop (under active development)": "Location sharing - pin drop (under active development)",
"Location sharing - share your current location with live updates (under active development)": "Location sharing - share your current location with live updates (under active development)",
"Live location sharing - share current location (active development, and temporarily, locations persist in room history)": "Live location sharing - share current location (active development, and temporarily, locations persist in room history)",
"Font size": "Font size",
"Use custom size": "Use custom size",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
@ -1518,8 +1522,9 @@
"Voice & Video": "Voice & Video",
"This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers",
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> 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.": "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> 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 <issueLink>submit a bug report</issueLink>.": "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.",
"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 <issueLink>submit a bug report</issueLink>.": "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.",
"Appearance": "Appearance",
"Show rooms with unread messages first": "Show rooms with unread messages first",
"Show previews of messages": "Show previews of messages",
@ -2882,9 +2896,13 @@
"Beta": "Beta",
"Leave the beta": "Leave the beta",
"Join the beta": "Join the beta",
"An error occured whilst sharing your live location": "An error occured whilst sharing your live location",
"You are sharing your live location": "You are sharing your live location",
"%(timeRemaining)s left": "%(timeRemaining)s left",
"An error occured whilst sharing your live location, please try again": "An error occured whilst sharing your live location, please try again",
"An error occurred while stopping your live location, please try again": "An error occurred while stopping your live location, please try again",
"Stop sharing": "Stop sharing",
"Stop sharing and close": "Stop sharing and close",
"Avatar": "Avatar",
"This room is public": "This room is public",
"Away": "Away",

View file

@ -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": {

View file

@ -20,6 +20,9 @@ import {
BeaconEvent,
MatrixEvent,
Room,
RoomMember,
RoomState,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import {
BeaconInfoState, makeBeaconContent, makeBeaconInfoContent,
@ -35,6 +38,7 @@ import {
ClearWatchCallback,
GeolocationError,
mapGeolocationPositionToTimedGeo,
sortBeaconsByLatestCreation,
TimedGeoUri,
watchPosition,
} from "../utils/beacon";
@ -45,13 +49,17 @@ const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconIn
export enum OwnBeaconStoreEvent {
LivenessChange = 'OwnBeaconStore.LivenessChange',
MonitoringLivePosition = 'OwnBeaconStore.MonitoringLivePosition',
WireError = 'WireError',
}
const MOVING_UPDATE_INTERVAL = 2000;
const STATIC_UPDATE_INTERVAL = 30000;
const BAIL_AFTER_CONSECUTIVE_ERROR_COUNT = 2;
type OwnBeaconStoreState = {
beacons: Map<string, Beacon>;
beaconWireErrors: Map<string, Beacon>;
beaconsByRoomId: Map<Room['roomId'], Set<string>>;
liveBeaconIds: string[];
};
@ -60,6 +68,16 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
// users beacons, keyed by event type
public readonly beacons = new Map<string, Beacon>();
public readonly beaconsByRoomId = new Map<Room['roomId'], Set<string>>();
/**
* 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<string, number>();
/**
* ids of live beacons
* ordered by creation time descending
*/
private liveBeaconIds = [];
private locationInterval: number;
private geolocationError: GeolocationError | undefined;
@ -90,6 +108,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
protected async onNotReady() {
this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness);
this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon);
this.matrixClient.removeListener(RoomStateEvent.Members, this.onRoomStateMembers);
this.beacons.forEach(beacon => beacon.destroy());
@ -97,11 +116,13 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
this.beacons.clear();
this.beaconsByRoomId.clear();
this.liveBeaconIds = [];
this.beaconWireErrorCounts.clear();
}
protected async onReady(): Promise<void> {
this.matrixClient.on(BeaconEvent.LivenessChange, this.onBeaconLiveness);
this.matrixClient.on(BeaconEvent.New, this.onNewBeacon);
this.matrixClient.on(RoomStateEvent.Members, this.onRoomStateMembers);
this.initialiseBeaconState();
}
@ -110,20 +131,51 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
// we don't actually do anything here
}
public hasLiveBeacons(roomId?: string): boolean {
public hasLiveBeacons = (roomId?: string): boolean => {
return !!this.getLiveBeaconIds(roomId).length;
}
};
public getLiveBeaconIds(roomId?: string): string[] {
/**
* Some live beacon has a wire error
* Optionally filter by room
*/
public hasWireErrors = (roomId?: string): boolean => {
return this.getLiveBeaconIds(roomId).some(this.beaconHasWireError);
};
/**
* If a beacon has failed to publish position
* past the allowed consecutive failure count (BAIL_AFTER_CONSECUTIVE_ERROR_COUNT)
* Then consider it to have an error
*/
public beaconHasWireError = (beaconId: string): boolean => {
return this.beaconWireErrorCounts.get(beaconId) >= BAIL_AFTER_CONSECUTIVE_ERROR_COUNT;
};
public resetWireError = (beaconId: string): void => {
this.incrementBeaconWireErrorCount(beaconId, false);
// always publish to all live beacons together
// instead of just one that was changed
// to keep lastPublishedTimestamp simple
// and extra published locations don't hurt
this.publishCurrentLocationToBeacons();
};
public getLiveBeaconIds = (roomId?: string): string[] => {
if (!roomId) {
return this.liveBeaconIds;
}
return this.liveBeaconIds.filter(beaconId => this.beaconsByRoomId.get(roomId)?.has(beaconId));
}
};
public getBeaconById(beaconId: string): Beacon | undefined {
public getLiveBeaconIdsWithWireError = (roomId?: string): string[] => {
return this.getLiveBeaconIds(roomId).filter(this.beaconHasWireError);
};
public getBeaconById = (beaconId: string): Beacon | undefined => {
return this.beacons.get(beaconId);
}
};
public stopBeacon = async (beaconInfoType: string): Promise<void> => {
const beacon = this.beacons.get(beaconInfoType);
@ -136,6 +188,10 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
return await this.updateBeaconEvent(beacon, { live: false });
};
/**
* Listeners
*/
private onNewBeacon = (_event: MatrixEvent, beacon: Beacon): void => {
if (!isOwnBeacon(beacon, this.matrixClient.getUserId())) {
return;
@ -160,6 +216,40 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
this.emit(OwnBeaconStoreEvent.LivenessChange, this.getLiveBeaconIds());
};
/**
* Check for changes in membership in rooms with beacons
* and stop monitoring beacons in rooms user is no longer member of
*/
private onRoomStateMembers = (_event: MatrixEvent, roomState: RoomState, member: RoomMember): void => {
// no beacons for this room, ignore
if (
!this.beaconsByRoomId.has(roomState.roomId) ||
member.userId !== this.matrixClient.getUserId()
) {
return;
}
// TODO check powerlevels here
// in PSF-797
// stop watching beacons in rooms where user is no longer a member
if (member.membership === 'leave' || member.membership === 'ban') {
this.beaconsByRoomId.get(roomState.roomId).forEach(this.removeBeacon);
this.beaconsByRoomId.delete(roomState.roomId);
}
};
/**
* State management
*/
/**
* Live beacon ids that do not have wire errors
*/
private get healthyLiveBeaconIds() {
return this.liveBeaconIds.filter(beaconId => !this.beaconHasWireError(beaconId));
}
private initialiseBeaconState = () => {
const userId = this.matrixClient.getUserId();
const visibleRooms = this.matrixClient.getVisibleRooms();
@ -187,10 +277,26 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
beacon.monitorLiveness();
};
/**
* Remove listeners for a given beacon
* remove from state
* and update liveness if changed
*/
private removeBeacon = (beaconId: string): void => {
if (!this.beacons.has(beaconId)) {
return;
}
this.beacons.get(beaconId).destroy();
this.beacons.delete(beaconId);
this.checkLiveness();
};
private checkLiveness = (): void => {
const prevLiveBeaconIds = this.getLiveBeaconIds();
this.liveBeaconIds = [...this.beacons.values()]
.filter(beacon => beacon.isLive)
.sort(sortBeaconsByLatestCreation)
.map(beacon => beacon.identifier);
const diff = arrayDiff(prevLiveBeaconIds, this.liveBeaconIds);
@ -218,20 +324,9 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
}
};
private updateBeaconEvent = async (beacon: Beacon, update: Partial<BeaconInfoState>): Promise<void> => {
const { description, timeout, timestamp, live, assetType } = {
...beacon.beaconInfo,
...update,
};
const updateContent = makeBeaconInfoContent(timeout,
live,
description,
assetType,
timestamp);
await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent);
};
/**
* Geolocation
*/
private togglePollingLocation = () => {
if (!!this.liveBeaconIds.length) {
@ -270,17 +365,6 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
this.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
};
private onWatchedPosition = (position: GeolocationPosition) => {
const timedGeoPosition = mapGeolocationPositionToTimedGeo(position);
// if this is our first position, publish immediateley
if (!this.lastPublishedPositionTimestamp) {
this.publishLocationToBeacons(timedGeoPosition);
} else {
this.debouncedPublishLocationToBeacons(timedGeoPosition);
}
};
private stopPollingLocation = () => {
clearInterval(this.locationInterval);
this.locationInterval = undefined;
@ -295,40 +379,14 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
this.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
};
/**
* Sends m.location events to all live beacons
* Sets last published beacon
*/
private publishLocationToBeacons = async (position: TimedGeoUri) => {
this.lastPublishedPositionTimestamp = Date.now();
// TODO handle failure in individual beacon without rejecting rest
await Promise.all(this.liveBeaconIds.map(beaconId =>
this.sendLocationToBeacon(this.beacons.get(beaconId), position)),
);
};
private onWatchedPosition = (position: GeolocationPosition) => {
const timedGeoPosition = mapGeolocationPositionToTimedGeo(position);
private debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL);
/**
* Sends m.location event to referencing given beacon
*/
private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri) => {
const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId);
await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content);
};
/**
* Gets the current location
* (as opposed to using watched location)
* and publishes it to all live beacons
*/
private publishCurrentLocationToBeacons = async () => {
try {
const position = await getCurrentPosition();
// TODO error handling
this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position));
} catch (error) {
this.onGeolocationError(error?.message);
// if this is our first position, publish immediateley
if (!this.lastPublishedPositionTimestamp) {
this.publishLocationToBeacons(timedGeoPosition);
} else {
this.debouncedPublishLocationToBeacons(timedGeoPosition);
}
};
@ -350,4 +408,89 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
// TODO may need adjustment when PSF-797 is done
await Promise.all(this.liveBeaconIds.map(this.stopBeacon));
};
/**
* Gets the current location
* (as opposed to using watched location)
* and publishes it to all live beacons
*/
private publishCurrentLocationToBeacons = async () => {
try {
const position = await getCurrentPosition();
this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position));
} catch (error) {
this.onGeolocationError(error?.message);
}
};
/**
* MatrixClient api
*/
private updateBeaconEvent = async (beacon: Beacon, update: Partial<BeaconInfoState>): Promise<void> => {
const { description, timeout, timestamp, live, assetType } = {
...beacon.beaconInfo,
...update,
};
const updateContent = makeBeaconInfoContent(timeout,
live,
description,
assetType,
timestamp);
await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent);
};
/**
* Sends m.location events to all live beacons
* Sets last published beacon
*/
private publishLocationToBeacons = async (position: TimedGeoUri) => {
this.lastPublishedPositionTimestamp = Date.now();
await Promise.all(this.healthyLiveBeaconIds.map(beaconId =>
this.sendLocationToBeacon(this.beacons.get(beaconId), position)),
);
};
private debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL);
/**
* Sends m.location event to referencing given beacon
*/
private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri) => {
const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId);
try {
await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content);
this.incrementBeaconWireErrorCount(beacon.identifier, false);
} catch (error) {
logger.error(error);
this.incrementBeaconWireErrorCount(beacon.identifier, true);
}
};
/**
* Manage beacon wire error count
* - clear count for beacon when not error
* - increment count for beacon when is error
* - emit if beacon error count crossed threshold
*/
private incrementBeaconWireErrorCount = (beaconId: string, isError: boolean): void => {
const hadError = this.beaconHasWireError(beaconId);
if (isError) {
// increment error count
this.beaconWireErrorCounts.set(
beaconId,
(this.beaconWireErrorCounts.get(beaconId) ?? 0) + 1,
);
} else {
// clear any error count
this.beaconWireErrorCounts.delete(beaconId);
}
if (this.beaconHasWireError(beaconId) !== hadError) {
this.emit(OwnBeaconStoreEvent.WireError, beaconId);
}
};
}

View file

@ -428,14 +428,14 @@ export class RoomViewStore extends Store<ActionPayload> {
}
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 = <div>
{ _t("Sorry, your homeserver is too old to participate in this room.") }<br />
description = <div>
{ _t("Sorry, your homeserver is too old to participate here.") }<br />
{ _t("Please contact your homeserver administrator.") }
</div>;
} else if (err.httpStatus === 404) {
@ -444,16 +444,16 @@ export class RoomViewStore extends Store<ActionPayload> {
if (invitingUserId) {
// if the inviting user is on the same HS, there can only be one cause: they left.
if (invitingUserId.endsWith(`:${this.matrixClient.getDomain()}`)) {
msg = _t("The person who invited you already left the room.");
description = _t("The person who invited you has already left.");
} else {
msg = _t("The person who invited you already left the room, or their server is offline.");
description = _t("The person who invited you has already left, or their server is offline.");
}
}
}
Modal.createTrackedDialog('Failed to join room', '', ErrorDialog, {
title: _t("Failed to join room"),
description: msg,
title: _t("Failed to join"),
description,
});
}

View file

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

View file

@ -82,12 +82,13 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
*/
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);
}

View file

@ -253,6 +253,9 @@ export default class RightPanelStore extends ReadyWatchingStore {
private filterValidCards(rightPanelForRoom?: IRightPanelForRoom) {
if (!rightPanelForRoom?.history) return;
rightPanelForRoom.history = rightPanelForRoom.history.filter((card) => this.isCardStateValid(card));
if (!rightPanelForRoom.history.length) {
rightPanelForRoom.isOpen = false;
}
}
private isCardStateValid(card: IRightPanelCard) {
@ -263,7 +266,11 @@ export default class RightPanelStore extends ReadyWatchingStore {
// or potentially other errors.
// (A nicer fix could be to indicate, that the right panel is loading if there is missing state data and re-emit if the data is available)
switch (card.phase) {
case RightPanelPhases.ThreadPanel:
if (!SettingsStore.getValue("feature_thread")) return false;
break;
case RightPanelPhases.ThreadView:
if (!SettingsStore.getValue("feature_thread")) return false;
if (!card.state.threadHeadEvent) {
console.warn("removed card from right panel because of missing threadHeadEvent in card state");
}

View file

@ -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 {

View file

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

View file

@ -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<IContent> {
export function makeReplyMixIn(ev?: MatrixEvent): IEventRelation {
if (!ev) return {};
const mixin: RecursivePartial<IContent> = {
'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) {

View file

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

View file

@ -54,6 +54,7 @@ const ALLOWED_BLOB_MIMETYPES = [
'image/png',
'image/apng',
'image/webp',
'image/avif',
'video/mp4',
'video/webm',

View file

@ -26,6 +26,7 @@ import RoomContext, { TimelineRenderingType } from '../../../../src/contexts/Roo
import { createAudioContext } from '../../../../src/audio/compat';
import { findByTestId, flushPromises } from '../../../test-utils';
import PlaybackWaveform from '../../../../src/components/views/audio_messages/PlaybackWaveform';
import SeekBar from "../../../../src/components/views/audio_messages/SeekBar";
jest.mock('../../../../src/audio/compat', () => ({
createAudioContext: jest.fn(),
@ -55,7 +56,7 @@ describe('<RecordingPlayback />', () => {
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<typeof RecordingPlayback>, room = defaultRoom) =>
mount(<RecordingPlayback {...props} />, {
wrappingComponent: RoomContext.Provider,
wrappingComponentProps: { value: room },
@ -127,34 +128,19 @@ describe('<RecordingPlayback />', () => {
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();
});
});

View file

@ -17,16 +17,22 @@ limitations under the License.
import React from 'react';
import { mocked } from 'jest-mock';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { Beacon } from 'matrix-js-sdk/src/matrix';
import LeftPanelLiveShareWarning from '../../../../src/components/views/beacon/LeftPanelLiveShareWarning';
import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../../src/stores/OwnBeaconStore';
import { flushPromises } from '../../../test-utils';
import { flushPromises, makeBeaconInfoEvent } from '../../../test-utils';
import dispatcher from '../../../../src/dispatcher/dispatcher';
import { Action } from '../../../../src/dispatcher/actions';
jest.mock('../../../../src/stores/OwnBeaconStore', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const EventEmitter = require("events");
class MockOwnBeaconStore extends EventEmitter {
public hasLiveBeacons = jest.fn().mockReturnValue(false);
public getLiveBeaconIdsWithWireError = jest.fn().mockReturnValue([]);
public getBeaconById = jest.fn();
public getLiveBeaconIds = jest.fn().mockReturnValue([]);
}
return {
// @ts-ignore
@ -43,32 +49,136 @@ describe('<LeftPanelLiveShareWarning />', () => {
const getComponent = (props = {}) =>
mount(<LeftPanelLiveShareWarning {...defaultProps} {...props} />);
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({});

View file

@ -25,6 +25,7 @@ import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../../src/stores/OwnB
import {
advanceDateAndTime,
findByTestId,
flushPromisesWithFakeTimers,
getMockClientWithEventEmitter,
makeBeaconInfoEvent,
mockGeolocation,
@ -95,10 +96,11 @@ describe('<RoomLiveShareWarning />', () => {
beforeEach(() => {
mockGeolocation();
jest.spyOn(global.Date, 'now').mockReturnValue(now);
mockClient.unstable_setLiveBeacon.mockClear();
mockClient.unstable_setLiveBeacon.mockReset().mockResolvedValue({ event_id: '1' });
});
afterEach(async () => {
jest.spyOn(OwnBeaconStore.instance, 'hasWireErrors').mockRestore();
await resetAsyncStoreWithClient(OwnBeaconStore.instance);
});
@ -236,13 +238,37 @@ describe('<RoomLiveShareWarning />', () => {
const component = getComponent({ roomId: room2Id });
act(() => {
findByTestId(component, 'room-live-share-stop-sharing').at(0).simulate('click');
findByTestId(component, 'room-live-share-primary-button').at(0).simulate('click');
component.setProps({});
});
expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledTimes(2);
expect(component.find('Spinner').length).toBeTruthy();
expect(findByTestId(component, 'room-live-share-stop-sharing').at(0).props().disabled).toBeTruthy();
expect(findByTestId(component, 'room-live-share-primary-button').at(0).props().disabled).toBeTruthy();
});
it('displays error when stop sharing fails', async () => {
const component = getComponent({ roomId: room1Id });
// fail first time
mockClient.unstable_setLiveBeacon
.mockRejectedValueOnce(new Error('oups'))
.mockResolvedValue(({ event_id: '1' }));
await act(async () => {
findByTestId(component, 'room-live-share-primary-button').at(0).simulate('click');
await flushPromisesWithFakeTimers();
});
component.setProps({});
expect(component.html()).toMatchSnapshot();
act(() => {
findByTestId(component, 'room-live-share-primary-button').at(0).simulate('click');
component.setProps({});
});
expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledTimes(2);
});
it('displays again with correct state after stopping a beacon', () => {
@ -251,7 +277,7 @@ describe('<RoomLiveShareWarning />', () => {
// stop the beacon
act(() => {
findByTestId(component, 'room-live-share-stop-sharing').at(0).simulate('click');
findByTestId(component, 'room-live-share-primary-button').at(0).simulate('click');
});
// time travel until room1Beacon1 is expired
act(() => {
@ -267,9 +293,83 @@ describe('<RoomLiveShareWarning />', () => {
});
// 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());
});
});
});
});

View file

@ -4,23 +4,73 @@ exports[`<LeftPanelLiveShareWarning /> when user has live location monitor rende
<LeftPanelLiveShareWarning
isMinimized={true}
>
<div
<AccessibleButton
className="mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__minimized"
element="div"
onClick={[Function]}
role="button"
tabIndex={0}
title="You are sharing your live location"
>
<div
height={10}
/>
</div>
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"
>
<div
height={10}
/>
</div>
</AccessibleButton>
</LeftPanelLiveShareWarning>
`;
exports[`<LeftPanelLiveShareWarning /> when user has live location monitor renders correctly when not minimized 1`] = `
<LeftPanelLiveShareWarning>
<div
<AccessibleButton
className="mx_LeftPanelLiveShareWarning"
element="div"
onClick={[Function]}
role="button"
tabIndex={0}
>
You are sharing your live location
</div>
<div
className="mx_AccessibleButton mx_LeftPanelLiveShareWarning"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
>
You are sharing your live location
</div>
</AccessibleButton>
</LeftPanelLiveShareWarning>
`;
exports[`<LeftPanelLiveShareWarning /> when user has live location monitor renders wire error 1`] = `
<LeftPanelLiveShareWarning>
<AccessibleButton
className="mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__error"
element="div"
onClick={[Function]}
role="button"
tabIndex={0}
>
<div
className="mx_AccessibleButton mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__error"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
>
An error occured whilst sharing your live location
</div>
</AccessibleButton>
</LeftPanelLiveShareWarning>
`;

View file

@ -1,5 +1,86 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available renders correctly with one live beacon in room 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">You are sharing your live location</span><span data-test-id=\\"room-live-share-expiry\\" class=\\"mx_RoomLiveShareWarning_expiry\\">1h left</span><button data-test-id=\\"room-live-share-stop-sharing\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Stop sharing</button></div>"`;
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available renders correctly with one live beacon in room 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">You are sharing your live location</span><span data-test-id=\\"room-live-share-expiry\\" class=\\"mx_RoomLiveShareWarning_expiry\\">1h left</span><button data-test-id=\\"room-live-share-primary-button\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Stop sharing</button></div>"`;
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available renders correctly with two live beacons in room 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">You are sharing your live location</span><span data-test-id=\\"room-live-share-expiry\\" class=\\"mx_RoomLiveShareWarning_expiry\\">12h left</span><button data-test-id=\\"room-live-share-stop-sharing\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Stop sharing</button></div>"`;
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available renders correctly with two live beacons in room 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">You are sharing your live location</span><span data-test-id=\\"room-live-share-expiry\\" class=\\"mx_RoomLiveShareWarning_expiry\\">12h left</span><button data-test-id=\\"room-live-share-primary-button\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Stop sharing</button></div>"`;
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available stopping beacons displays error when stop sharing fails 1`] = `"<div class=\\"mx_RoomLiveShareWarning\\"><div class=\\"mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon mx_StyledLiveBeaconIcon_error\\"></div><span class=\\"mx_RoomLiveShareWarning_label\\">An error occurred while stopping your live location, please try again</span><button data-test-id=\\"room-live-share-primary-button\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger\\">Retry</button></div>"`;
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available with wire errors displays wire error when mounted with wire errors 1`] = `
<RoomLiveShareWarning
roomId="$room2:server.org"
>
<RoomLiveShareWarningInner
liveBeaconIds={
Array [
"org.matrix.msc3489.beacon_info.@alice:server.org.3",
"org.matrix.msc3489.beacon_info.@alice:server.org.4",
]
}
roomId="$room2:server.org"
>
<div
className="mx_RoomLiveShareWarning"
>
<StyledLiveBeaconIcon
className="mx_RoomLiveShareWarning_icon"
withError={true}
>
<div
className="mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon mx_StyledLiveBeaconIcon_error"
/>
</StyledLiveBeaconIcon>
<span
className="mx_RoomLiveShareWarning_label"
>
An error occured whilst sharing your live location, please try again
</span>
<AccessibleButton
data-test-id="room-live-share-primary-button"
disabled={false}
element="button"
kind="danger"
onClick={[Function]}
role="button"
tabIndex={0}
>
<button
className="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
data-test-id="room-live-share-primary-button"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
>
Retry
</button>
</AccessibleButton>
<AccessibleButton
className="mx_RoomLiveShareWarning_closeButton"
data-test-id="room-live-share-wire-error-close-button"
element="button"
onClick={[Function]}
role="button"
tabIndex={0}
title="Stop sharing and close"
>
<button
className="mx_AccessibleButton mx_RoomLiveShareWarning_closeButton"
data-test-id="room-live-share-wire-error-close-button"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Stop sharing and close"
>
<div
className="mx_RoomLiveShareWarning_closeButtonIcon"
/>
</button>
</AccessibleButton>
</div>
</RoomLiveShareWarningInner>
</RoomLiveShareWarning>
`;

View file

@ -107,7 +107,7 @@ describe('<RoomPreviewBar />', () => {
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 });

View file

@ -54,11 +54,11 @@ exports[`<RoomPreviewBar /> with an error renders other errors 1`] = `
RoomPreviewBar-test-room is not accessible at this time.
</h3>
<p>
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.
</p>
<p>
<span>
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
<a
href="https://github.com/vector-im/element-web/issues/new/choose"
rel="noreferrer noopener"
@ -80,7 +80,7 @@ exports[`<RoomPreviewBar /> with an error renders room not found error 1`] = `
RoomPreviewBar-test-room does not exist.
</h3>
<p>
This room doesn't exist. Are you sure you're at the right place?
Are you sure you're at the right place?
</p>
</div>
`;
@ -93,7 +93,7 @@ exports[`<RoomPreviewBar /> with an invite with an invited email when client fai
Something went wrong with your invite to RoomPreviewBar-test-room
</h3>
<p>
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.
</p>
</div>
`;

View file

@ -14,7 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room, Beacon, BeaconEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import {
Room,
Beacon,
BeaconEvent,
MatrixEvent,
RoomStateEvent,
RoomMember,
} from "matrix-js-sdk/src/matrix";
import { makeBeaconContent } from "matrix-js-sdk/src/content-helpers";
import { M_BEACON, M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon";
import { logger } from "matrix-js-sdk/src/logger";
@ -23,6 +30,7 @@ import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconS
import {
advanceDateAndTime,
flushPromisesWithFakeTimers,
makeMembershipEvent,
resetAsyncStoreWithClient,
setupAsyncStoreWithClient,
} from "../test-utils";
@ -158,7 +166,7 @@ describe('OwnBeaconStore', () => {
geolocation = mockGeolocation();
mockClient.getVisibleRooms.mockReturnValue([]);
mockClient.unstable_setLiveBeacon.mockClear().mockResolvedValue({ event_id: '1' });
mockClient.sendEvent.mockClear().mockResolvedValue({ event_id: '1' });
mockClient.sendEvent.mockReset().mockResolvedValue({ event_id: '1' });
jest.spyOn(global.Date, 'now').mockReturnValue(now);
jest.spyOn(OwnBeaconStore.instance, 'emit').mockRestore();
jest.spyOn(logger, 'error').mockRestore();
@ -243,6 +251,7 @@ describe('OwnBeaconStore', () => {
expect(removeSpy.mock.calls[0]).toEqual(expect.arrayContaining([BeaconEvent.LivenessChange]));
expect(removeSpy.mock.calls[1]).toEqual(expect.arrayContaining([BeaconEvent.New]));
expect(removeSpy.mock.calls[2]).toEqual(expect.arrayContaining([RoomStateEvent.Members]));
});
it('destroys beacons', async () => {
@ -509,6 +518,112 @@ describe('OwnBeaconStore', () => {
});
});
describe('on room membership changes', () => {
it('ignores events for rooms without beacons', async () => {
const membershipEvent = makeMembershipEvent(room2Id, aliceId);
// no beacons for room2
const [, room2] = makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
const store = await makeOwnBeaconStore();
const emitSpy = jest.spyOn(store, 'emit');
const oldLiveBeaconIds = store.getLiveBeaconIds();
mockClient.emit(
RoomStateEvent.Members,
membershipEvent,
room2.currentState,
new RoomMember(room2Id, aliceId),
);
expect(emitSpy).not.toHaveBeenCalled();
// strictly equal
expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds);
});
it('ignores events for membership changes that are not current user', async () => {
// bob joins room1
const membershipEvent = makeMembershipEvent(room1Id, bobId);
const member = new RoomMember(room1Id, bobId);
member.setMembershipEvent(membershipEvent);
const [room1] = makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
const store = await makeOwnBeaconStore();
const emitSpy = jest.spyOn(store, 'emit');
const oldLiveBeaconIds = store.getLiveBeaconIds();
mockClient.emit(
RoomStateEvent.Members,
membershipEvent,
room1.currentState,
member,
);
expect(emitSpy).not.toHaveBeenCalled();
// strictly equal
expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds);
});
it('ignores events for membership changes that are not leave/ban', async () => {
// alice joins room1
const membershipEvent = makeMembershipEvent(room1Id, aliceId);
const member = new RoomMember(room1Id, aliceId);
member.setMembershipEvent(membershipEvent);
const [room1] = makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
alicesRoom2BeaconInfo,
]);
const store = await makeOwnBeaconStore();
const emitSpy = jest.spyOn(store, 'emit');
const oldLiveBeaconIds = store.getLiveBeaconIds();
mockClient.emit(
RoomStateEvent.Members,
membershipEvent,
room1.currentState,
member,
);
expect(emitSpy).not.toHaveBeenCalled();
// strictly equal
expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds);
});
it('destroys and removes beacons when current user leaves room', async () => {
// alice leaves room1
const membershipEvent = makeMembershipEvent(room1Id, aliceId, 'leave');
const member = new RoomMember(room1Id, aliceId);
member.setMembershipEvent(membershipEvent);
const [room1] = makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
alicesRoom2BeaconInfo,
]);
const store = await makeOwnBeaconStore();
const room1BeaconInstance = store.beacons.get(alicesRoom1BeaconInfo.getType());
const beaconDestroySpy = jest.spyOn(room1BeaconInstance, 'destroy');
const emitSpy = jest.spyOn(store, 'emit');
mockClient.emit(
RoomStateEvent.Members,
membershipEvent,
room1.currentState,
member,
);
expect(emitSpy).toHaveBeenCalledWith(
OwnBeaconStoreEvent.LivenessChange,
// other rooms beacons still live
[alicesRoom2BeaconInfo.getType()],
);
expect(beaconDestroySpy).toHaveBeenCalledTimes(1);
expect(store.getLiveBeaconIds(room1Id)).toEqual([]);
});
});
describe('stopBeacon()', () => {
beforeEach(() => {
makeRoomsWithStateEvents([
@ -581,7 +696,7 @@ describe('OwnBeaconStore', () => {
});
});
describe('sending positions', () => {
describe('publishing positions', () => {
it('stops watching position when user has no more live beacons', async () => {
// geolocation is only going to emit 1 position
geolocation.watchPosition.mockImplementation(
@ -710,6 +825,141 @@ describe('OwnBeaconStore', () => {
});
});
describe('when publishing position fails', () => {
beforeEach(() => {
geolocation.watchPosition.mockImplementation(
watchPositionMockImplementation([0, 1000, 3000, 3000, 3000]),
);
// eat expected console error logs
jest.spyOn(logger, 'error').mockImplementation(() => { });
});
// we need to advance time and then flush promises
// individually for each call to sendEvent
// otherwise the sendEvent doesn't reject/resolve and update state
// before the next call
// advance and flush every 1000ms
// until given ms is 'elapsed'
const advanceAndFlushPromises = async (timeMs: number) => {
while (timeMs > 0) {
jest.advanceTimersByTime(1000);
await flushPromisesWithFakeTimers();
timeMs -= 1000;
}
};
it('continues publishing positions after one publish error', async () => {
// fail to send first event, then succeed
mockClient.sendEvent.mockRejectedValueOnce(new Error('oups')).mockResolvedValue({ event_id: '1' });
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
const store = await makeOwnBeaconStore();
// wait for store to settle
await flushPromisesWithFakeTimers();
await advanceAndFlushPromises(50000);
// called for each position from watchPosition
expect(mockClient.sendEvent).toHaveBeenCalledTimes(5);
expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false);
expect(store.hasWireErrors()).toBe(false);
});
it('continues publishing positions when a beacon fails intermittently', async () => {
// every second event rejects
// meaning this beacon has more errors than the threshold
// but they are not consecutive
mockClient.sendEvent
.mockRejectedValueOnce(new Error('oups'))
.mockResolvedValueOnce({ event_id: '1' })
.mockRejectedValueOnce(new Error('oups'))
.mockResolvedValueOnce({ event_id: '1' })
.mockRejectedValueOnce(new Error('oups'));
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
const store = await makeOwnBeaconStore();
const emitSpy = jest.spyOn(store, 'emit');
// wait for store to settle
await flushPromisesWithFakeTimers();
await advanceAndFlushPromises(50000);
// called for each position from watchPosition
expect(mockClient.sendEvent).toHaveBeenCalledTimes(5);
expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false);
expect(store.hasWireErrors()).toBe(false);
expect(emitSpy).not.toHaveBeenCalledWith(
OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(),
);
});
it('stops publishing positions when a beacon fails consistently', async () => {
// always fails to send events
mockClient.sendEvent.mockRejectedValue(new Error('oups'));
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
const store = await makeOwnBeaconStore();
const emitSpy = jest.spyOn(store, 'emit');
// wait for store to settle
await flushPromisesWithFakeTimers();
// 5 positions from watchPosition in this period
await advanceAndFlushPromises(50000);
// only two allowed failures
expect(mockClient.sendEvent).toHaveBeenCalledTimes(2);
expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(true);
expect(store.hasWireErrors()).toBe(true);
expect(emitSpy).toHaveBeenCalledWith(
OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(),
);
});
it('restarts publishing a beacon after resetting wire error', async () => {
// always fails to send events
mockClient.sendEvent.mockRejectedValue(new Error('oups'));
makeRoomsWithStateEvents([
alicesRoom1BeaconInfo,
]);
const store = await makeOwnBeaconStore();
const emitSpy = jest.spyOn(store, 'emit');
// wait for store to settle
await flushPromisesWithFakeTimers();
// 3 positions from watchPosition in this period
await advanceAndFlushPromises(4000);
// only two allowed failures
expect(mockClient.sendEvent).toHaveBeenCalledTimes(2);
expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(true);
expect(store.hasWireErrors()).toBe(true);
expect(store.hasWireErrors(room1Id)).toBe(true);
expect(emitSpy).toHaveBeenCalledWith(
OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(),
);
// reset emitSpy mock counts to asser on wireError again
emitSpy.mockClear();
store.resetWireError(alicesRoom1BeaconInfo.getType());
expect(store.beaconHasWireError(alicesRoom1BeaconInfo.getType())).toBe(false);
// 2 more positions from watchPosition in this period
await advanceAndFlushPromises(10000);
// 2 from before, 2 new ones
expect(mockClient.sendEvent).toHaveBeenCalledTimes(4);
expect(emitSpy).toHaveBeenCalledWith(
OwnBeaconStoreEvent.WireError, alicesRoom1BeaconInfo.getType(),
);
});
});
it('publishes subsequent positions', async () => {
// modern fake timers + debounce + promises are not friends
// just testing that positions are published

View file

@ -2,6 +2,7 @@ export * from './beacon';
export * from './client';
export * from './location';
export * from './platform';
export * from './room';
export * from './test-utils';
export * from './voice';
export * from './wrappers';

34
test/test-utils/room.ts Normal file
View file

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

View file

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

View file

@ -2197,9 +2197,9 @@ ansi-escapes@^4.2.1:
type-fest "^0.21.3"
ansi-regex@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
version "4.1.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed"
integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==
ansi-regex@^5.0.0, ansi-regex@^5.0.1:
version "5.0.1"