mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-15 20:54:59 +08:00
Merge branch 'develop' into travis/remove-skinning
This commit is contained in:
commit
97efdf7094
2
.github/workflows/test_coverage.yml
vendored
2
.github/workflows/test_coverage.yml
vendored
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -28,3 +28,8 @@ limitations under the License.
|
||||
// colors icon
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mx_StyledLiveBeaconIcon.mx_StyledLiveBeaconIcon_error {
|
||||
background-color: $alert;
|
||||
border-color: $alert;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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
20
src/Editing.ts
Normal 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}`;
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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> }
|
||||
|
@ -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;
|
||||
|
@ -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 });
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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") {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 },
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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++;
|
||||
|
@ -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",
|
||||
|
@ -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": {
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -54,6 +54,7 @@ const ALLOWED_BLOB_MIMETYPES = [
|
||||
'image/png',
|
||||
'image/apng',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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({});
|
||||
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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 });
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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
|
||||
|
@ -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
34
test/test-utils/room.ts
Normal 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(),
|
||||
});
|
||||
|
@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user