Merge remote-tracking branch 'origin/develop' into feat/add-message-edition-wysiwyg-composer

pull/28788/head^2
Florian Duros 2022-10-24 14:41:27 +02:00
commit de86221c72
No known key found for this signature in database
GPG Key ID: 9700AA5870258A0B
55 changed files with 1551 additions and 668 deletions

View File

@ -1 +1 @@
14
16

View File

@ -235,7 +235,7 @@ describe("Sliding Sync", () => {
"Test Room", "Dummy",
]);
cy.contains(".mx_RoomTile", "Test Room").get(".mx_NotificationBadge").should("not.exist");
cy.contains(".mx_RoomTile", "Test Room").get(".mx_NotificationBadge").should("not.be.visible");
});
it("should update user settings promptly", () => {

View File

@ -426,7 +426,7 @@ $left-gutter: 64px;
}
&.mx_EventTile_selected .mx_EventTile_line {
// TODO: check if this would be necessary
/* TODO: check if this would be necessary; */
padding-inline-start: calc(var(--EventTile_group_line-spacing-inline-start) + 20px);
}
}
@ -894,15 +894,22 @@ $left-gutter: 64px;
}
/* Display notification dot */
&[data-notification]::before {
&[data-notification]::before,
.mx_NotificationBadge {
position: absolute;
$notification-inset-block-start: 14px; /* 14px: align the dot with the timestamp row */
width: $notification-dot-size;
height: $notification-dot-size;
/* !important to fix overly specific CSS selector applied on mx_NotificationBadge */
width: $notification-dot-size !important;
height: $notification-dot-size !important;
border-radius: 50%;
inset: $notification-inset-block-start $spacing-8 auto auto;
}
.mx_NotificationBadge_count {
display: none;
}
&[data-notification="total"]::before {
background-color: $room-icon-unread-color;
}
@ -1301,7 +1308,8 @@ $left-gutter: 64px;
}
}
&[data-shape="ThreadsList"][data-notification]::before {
&[data-shape="ThreadsList"][data-notification]::before,
.mx_NotificationBadge {
/* stylelint-disable-next-line declaration-colon-space-after */
inset-block-start:
calc($notification-inset-block-start - var(--MatrixChat_useCompactLayout_group-padding-top));

View File

@ -78,15 +78,23 @@ export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Pr
}
}
export function getUnreadNotificationCount(room: Room, type: NotificationCountType = null): number {
let notificationCount = room.getUnreadNotificationCount(type);
export function getUnreadNotificationCount(
room: Room,
type: NotificationCountType,
threadId?: string,
): number {
let notificationCount = (!!threadId
? room.getThreadUnreadNotificationCount(threadId, type)
: room.getUnreadNotificationCount(type));
// Check notification counts in the old room just in case there's some lost
// there. We only go one level down to avoid performance issues, and theory
// is that 1st generation rooms will have already been read by the 3rd generation.
const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, "");
if (createEvent && createEvent.getContent()['predecessor']) {
const oldRoomId = createEvent.getContent()['predecessor']['room_id'];
const predecessor = createEvent?.getContent().predecessor;
// Exclude threadId, as the same thread can't continue over a room upgrade
if (!threadId && predecessor) {
const oldRoomId = predecessor.room_id;
const oldRoom = MatrixClientPeg.get().getRoom(oldRoomId);
if (oldRoom) {
// We only ever care if there's highlights in the old room. No point in

View File

@ -23,7 +23,6 @@ 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
@ -77,11 +76,6 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
return false;
}
} else {
const threadState = RoomNotificationStateStore.instance.getThreadsRoomState(room);
if (threadState.color > 0) {
return true;
}
}
// if the read receipt relates to an event is that part of a thread

View File

@ -60,6 +60,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
private recorderProcessor: ScriptProcessorNode;
private recording = false;
private observable: SimpleObservable<IRecordingUpdate>;
private targetMaxLength: number | null = TARGET_MAX_LENGTH;
public amplitudes: number[] = []; // at each second mark, generated
private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0);
public onDataAvailable: (data: ArrayBuffer) => void;
@ -83,6 +84,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
return true; // we don't ever care if the event had listeners, so just return "yes"
}
public disableMaxLength(): void {
this.targetMaxLength = null;
}
private async makeRecorder() {
try {
this.recorderStream = await navigator.mediaDevices.getUserMedia({
@ -203,6 +208,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
// In testing, recorder time and worker time lag by about 400ms, which is roughly the
// time needed to encode a sample/frame.
//
if (!this.targetMaxLength) {
// skip time checks if max length has been disabled
return;
}
const secondsLeft = TARGET_MAX_LENGTH - this.recorderSeconds;
if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame
// noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping

View File

@ -22,6 +22,7 @@ import classNames from 'classnames';
import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync';
import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials';
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { MatrixError } from 'matrix-js-sdk/src/matrix';
import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard';
import PageTypes from '../../PageTypes';
@ -288,8 +289,8 @@ class LoggedInView extends React.Component<IProps, IState> {
};
private onSync = (syncState: SyncState, oldSyncState?: SyncState, data?: ISyncStateData): void => {
const oldErrCode = this.state.syncErrorData?.error?.errcode;
const newErrCode = data && data.error && data.error.errcode;
const oldErrCode = (this.state.syncErrorData?.error as MatrixError)?.errcode;
const newErrCode = (data?.error as MatrixError)?.errcode;
if (syncState === oldSyncState && oldErrCode === newErrCode) return;
this.setState({
@ -317,9 +318,9 @@ class LoggedInView extends React.Component<IProps, IState> {
};
private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
const error = (syncError?.error as MatrixError)?.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
if (error) {
usageLimitEventContent = syncError.error.data as IUsageLimit;
usageLimitEventContent = (syncError?.error as MatrixError).data as IUsageLimit;
}
// usageLimitDismissed is true when the user has explicitly hidden the toast

View File

@ -24,7 +24,6 @@ import {
MatrixEventEvent,
} from 'matrix-js-sdk/src/matrix';
import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync';
import { MatrixError } from 'matrix-js-sdk/src/http-api';
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
@ -203,7 +202,7 @@ interface IState {
// When showing Modal dialogs we need to set aria-hidden on the root app element
// and disable it when there are no dialogs
hideToSRUsers: boolean;
syncError?: MatrixError;
syncError?: Error;
resizeNotifier: ResizeNotifier;
serverConfig?: ValidatedServerConfig;
ready: boolean;
@ -1457,7 +1456,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (data.error instanceof InvalidStoreError) {
Lifecycle.handleInvalidStoreError(data.error);
}
this.setState({ syncError: data.error || {} as MatrixError });
this.setState({ syncError: data.error });
} else if (this.state.syncError) {
this.setState({ syncError: null });
}

View File

@ -34,10 +34,12 @@ const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
export function getUnsentMessages(room: Room): MatrixEvent[] {
export function getUnsentMessages(room: Room, threadId?: string): MatrixEvent[] {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
const isNotSent = ev.status === EventStatus.NOT_SENT;
const belongsToTheThread = threadId === ev.threadRootId;
return isNotSent && (!threadId || belongsToTheThread);
});
}

View File

@ -16,11 +16,10 @@ limitations under the License.
import React, { ComponentProps } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import classNames from "classnames";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
@ -39,11 +38,7 @@ interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idNam
oobData?: IOOBData & {
roomId?: string;
};
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean;
className?: string;
onClick?(): void;
}
@ -72,10 +67,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
}
public componentWillUnmount() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
}
MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
}
public static getDerivedStateFromProps(nextProps: IProps): IState {
@ -133,7 +125,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
public render() {
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
const roomName = room ? room.name : oobData.name;
const roomName = room?.name ?? oobData.name;
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId;
@ -142,7 +134,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
<BaseAvatar
{...otherProps}
className={classNames(className, {
mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(),
mx_RoomAvatar_isSpaceRoom: (room?.getType() ?? this.props.oobData?.roomType) === RoomType.Space,
})}
name={roomName}
idName={idName}

View File

@ -47,7 +47,7 @@ const ShareLatestLocation: React.FC<Props> = ({ latestLocationState }) => {
return <>
<TooltipTarget label={_t('Open in OpenStreetMap')}>
<a
data-test-id='open-location-in-osm'
data-testid='open-location-in-osm'
href={mapLink}
target='_blank'
rel='noreferrer noopener'

View File

@ -93,6 +93,7 @@ import { TooltipOption } from "./TooltipOption";
import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom";
import { useSlidingSyncRoomSearch } from "../../../../hooks/useSlidingSyncRoomSearch";
import { shouldShowFeedback } from "../../../../utils/Feedback";
import RoomAvatar from "../../avatars/RoomAvatar";
const MAX_RECENT_SEARCHES = 10;
const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons
@ -656,6 +657,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
shouldPeek: result.publicRoom.world_readable || cli.isGuest(),
}, true, ev.type !== "click");
};
return (
<Option
id={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}`}
@ -674,13 +676,14 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
aria-describedby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_alias`}
aria-details={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_details`}
>
<BaseAvatar
<RoomAvatar
className="mx_SearchResultAvatar"
url={result?.publicRoom?.avatar_url
? mediaFromMxc(result?.publicRoom?.avatar_url).getSquareThumbnailHttp(AVATAR_SIZE)
: null}
name={result.publicRoom.name}
idName={result.publicRoom.room_id}
oobData={{
roomId: result.publicRoom.room_id,
name: result.publicRoom.name,
avatarUrl: result.publicRoom.avatar_url,
roomType: result.publicRoom.room_type,
}}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
/>

View File

@ -20,7 +20,8 @@ limitations under the License.
import React from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { _t } from '../../../languageHandler';
import HeaderButton from './HeaderButton';
@ -43,6 +44,7 @@ import { SummarizedNotificationState } from "../../../stores/notifications/Summa
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import PosthogTrackers from "../../../PosthogTrackers";
import { ButtonEvent } from "../elements/AccessibleButton";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
const ROOM_INFO_PHASES = [
RightPanelPhases.RoomSummary,
@ -136,32 +138,67 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
private threadNotificationState: ThreadsRoomNotificationState;
private globalNotificationState: SummarizedNotificationState;
private get supportsThreadNotifications(): boolean {
const client = MatrixClientPeg.get();
return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported;
}
constructor(props: IProps) {
super(props, HeaderKind.Room);
this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room);
if (!this.supportsThreadNotifications) {
this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room);
}
this.globalNotificationState = RoomNotificationStateStore.instance.globalState;
}
public componentDidMount(): void {
super.componentDidMount();
this.threadNotificationState.on(NotificationStateEvents.Update, this.onThreadNotification);
if (!this.supportsThreadNotifications) {
this.threadNotificationState?.on(NotificationStateEvents.Update, this.onNotificationUpdate);
} else {
this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
}
this.onNotificationUpdate();
RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus);
}
public componentWillUnmount(): void {
super.componentWillUnmount();
this.threadNotificationState.off(NotificationStateEvents.Update, this.onThreadNotification);
if (!this.supportsThreadNotifications) {
this.threadNotificationState?.off(NotificationStateEvents.Update, this.onNotificationUpdate);
} else {
this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
}
RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus);
}
private onThreadNotification = (): void => {
private onNotificationUpdate = (): void => {
let threadNotificationColor: NotificationColor;
if (!this.supportsThreadNotifications) {
threadNotificationColor = this.threadNotificationState.color;
} else {
threadNotificationColor = this.notificationColor;
}
// console.log
// XXX: why don't we read from this.state.threadNotificationColor in the render methods?
this.setState({
threadNotificationColor: this.threadNotificationState.color,
threadNotificationColor,
});
};
private get notificationColor(): NotificationColor {
switch (this.props.room.threadsAggregateNotificationType) {
case NotificationCountType.Highlight:
return NotificationColor.Red;
case NotificationCountType.Total:
return NotificationColor.Grey;
default:
return NotificationColor.None;
}
}
private onUpdateStatus = (notificationState: SummarizedNotificationState): void => {
// XXX: why don't we read from this.state.globalNotificationCount in the render methods?
this.globalNotificationState = notificationState;
@ -255,12 +292,13 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
? <HeaderButton
key={RightPanelPhases.ThreadPanel}
name="threadsButton"
data-testid="threadsButton"
title={_t("Threads")}
onClick={this.onThreadsPanelClicked}
isHighlighted={this.isPhase(RoomHeaderButtons.THREAD_PHASES)}
isUnread={this.threadNotificationState.color > 0}
isUnread={this.state.threadNotificationColor > 0}
>
<UnreadIndicator color={this.threadNotificationState.color} />
<UnreadIndicator color={this.state.threadNotificationColor} />
</HeaderButton>
: null,
);

View File

@ -27,6 +27,7 @@ import { NotificationCountType, Room, RoomEvent } from 'matrix-js-sdk/src/models
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { UserTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
import { Feature, ServerSupport } from 'matrix-js-sdk/src/feature';
import { Icon as LinkIcon } from '../../../../res/img/element-icons/link.svg';
import { Icon as ViewInRoomIcon } from '../../../../res/img/element-icons/view-in-room.svg';
@ -84,6 +85,7 @@ import { useTooltip } from "../../../utils/useTooltip";
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom';
import { ElementCall } from "../../../models/Call";
import { UnreadNotificationBadge } from './NotificationBadge/UnreadNotificationBadge';
export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations;
@ -113,7 +115,7 @@ export interface IEventTileType extends React.Component {
getEventTileOps?(): IEventTileOps;
}
interface IProps {
export interface EventTileProps {
// the MatrixEvent to show
mxEvent: MatrixEvent;
@ -248,7 +250,7 @@ interface IState {
}
// MUST be rendered within a RoomContext with a set timelineRenderingType
export class UnwrappedEventTile extends React.Component<IProps, IState> {
export class UnwrappedEventTile extends React.Component<EventTileProps, IState> {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
private tile = React.createRef<IEventTileType>();
@ -267,7 +269,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
constructor(props: EventTileProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
const thread = this.thread;
@ -394,7 +396,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.on(ThreadEvent.Update, this.updateThread);
if (this.thread) {
if (this.thread && !this.supportsThreadNotifications) {
this.setupNotificationListener(this.thread);
}
}
@ -405,33 +407,40 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
room?.on(ThreadEvent.New, this.onNewThread);
}
private get supportsThreadNotifications(): boolean {
const client = MatrixClientPeg.get();
return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported;
}
private setupNotificationListener(thread: Thread): void {
const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room);
this.threadState = notifications.getThreadRoomState(thread);
this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate);
this.onThreadStateUpdate();
if (!this.supportsThreadNotifications) {
const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room);
this.threadState = notifications.getThreadRoomState(thread);
this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate);
this.onThreadStateUpdate();
}
}
private onThreadStateUpdate = (): void => {
let threadNotification = null;
switch (this.threadState?.color) {
case NotificationColor.Grey:
threadNotification = NotificationCountType.Total;
break;
case NotificationColor.Red:
threadNotification = NotificationCountType.Highlight;
break;
}
if (!this.supportsThreadNotifications) {
let threadNotification = null;
switch (this.threadState?.color) {
case NotificationColor.Grey:
threadNotification = NotificationCountType.Total;
break;
case NotificationColor.Red:
threadNotification = NotificationCountType.Highlight;
break;
}
this.setState({
threadNotification,
});
this.setState({
threadNotification,
});
}
};
private updateThread = (thread: Thread) => {
if (thread !== this.state.thread) {
if (thread !== this.state.thread && !this.supportsThreadNotifications) {
if (this.threadState) {
this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
}
@ -444,7 +453,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(nextProps: IProps) {
UNSAFE_componentWillReceiveProps(nextProps: EventTileProps) {
// re-check the sender verification as outgoing events progress through
// the send process.
if (nextProps.eventSendStatus !== this.props.eventSendStatus) {
@ -452,7 +461,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
}
}
shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean {
shouldComponentUpdate(nextProps: EventTileProps, nextState: IState): boolean {
if (objectHasDiff(this.state, nextState)) {
return true;
}
@ -481,7 +490,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
}
}
componentDidUpdate(prevProps: IProps, prevState: IState, snapshot) {
componentDidUpdate() {
// If we're not listening for receipts and expect to be, register a listener.
if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) {
MatrixClientPeg.get().on(RoomEvent.Receipt, this.onRoomReceipt);
@ -667,7 +676,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
}, this.props.onHeightChanged); // Decryption may have caused a change in size
}
private propsEqual(objA: IProps, objB: IProps): boolean {
private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean {
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
@ -1348,6 +1357,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
]);
}
case TimelineRenderingType.ThreadsList: {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return (
React.createElement(this.props.as || "li", {
@ -1361,7 +1371,9 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
"data-shape": this.context.timelineRenderingType,
"data-self": isOwnEvent,
"data-has-reply": !!replyChain,
"data-notification": this.state.threadNotification,
"data-notification": !this.supportsThreadNotifications
? this.state.threadNotification
: undefined,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onClick": (ev: MouseEvent) => {
@ -1409,6 +1421,9 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
</RovingAccessibleTooltipButton>
</Toolbar>
{ msgOption }
<UnreadNotificationBadge
room={room}
threadId={this.props.mxEvent.getId()} />
</>)
);
}
@ -1512,7 +1527,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
}
// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
const SafeEventTile = forwardRef((props: IProps, ref: RefObject<UnwrappedEventTile>) => {
const SafeEventTile = forwardRef((props: EventTileProps, ref: RefObject<UnwrappedEventTile>) => {
return <TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout}>
<UnwrappedEventTile ref={ref} {...props} />
</TileErrorBoundary>;

View File

@ -175,14 +175,22 @@ const NewRoomIntro = () => {
}
const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url;
body = <React.Fragment>
<MiniAvatarUploader
hasAvatar={!!avatarUrl}
let avatar = (
<RoomAvatar room={room} width={AVATAR_SIZE} height={AVATAR_SIZE} viewAvatarOnClick={!!avatarUrl} />
);
if (!avatarUrl) {
avatar = <MiniAvatarUploader
hasAvatar={false}
noAvatarLabel={_t("Add a photo, so people can easily spot your room.")}
setAvatarUrl={url => cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, '')}
>
<RoomAvatar room={room} width={AVATAR_SIZE} height={AVATAR_SIZE} viewAvatarOnClick={true} />
</MiniAvatarUploader>
{ avatar }
</MiniAvatarUploader>;
}
body = <React.Fragment>
{ avatar }
<h2>{ room.name }</h2>

View File

@ -15,16 +15,14 @@ limitations under the License.
*/
import React, { MouseEvent } from "react";
import classNames from "classnames";
import { formatCount } from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common";
import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import Tooltip from "../elements/Tooltip";
import { _t } from "../../../languageHandler";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { StatelessNotificationBadge } from "./NotificationBadge/StatelessNotificationBadge";
interface IProps {
notification: NotificationState;
@ -113,61 +111,25 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
public render(): React.ReactElement {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { notification, showUnsentTooltip, forceCount, roomId, onClick, ...props } = this.props;
const { notification, showUnsentTooltip, onClick } = this.props;
// Don't show a badge if we don't need to
if (notification.isIdle) return null;
// TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/element-web/issues/14261
// As of writing, that is "if red, show count always" and "optionally show counts instead of dots".
// See git diff for what that boolean state looks like.
// XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
const hasAnySymbol = notification.symbol || notification.count > 0;
let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount;
if (forceCount) {
isEmptyBadge = false;
if (!notification.hasUnreadCount) return null; // Can't render a badge
let label: string;
let tooltip: JSX.Element;
if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) {
label = _t("Message didn't send. Click for info.");
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={label} />;
}
let symbol = notification.symbol || formatCount(notification.count);
if (isEmptyBadge) symbol = "";
const classes = classNames({
'mx_NotificationBadge': true,
'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount,
'mx_NotificationBadge_highlighted': notification.hasMentions,
'mx_NotificationBadge_dot': isEmptyBadge,
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
'mx_NotificationBadge_3char': symbol.length > 2,
});
if (onClick) {
let label: string;
let tooltip: JSX.Element;
if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) {
label = _t("Message didn't send. Click for info.");
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={label} />;
}
return (
<AccessibleButton
aria-label={label}
{...props}
className={classes}
onClick={onClick}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>
<span className="mx_NotificationBadge_count">{ symbol }</span>
{ tooltip }
</AccessibleButton>
);
}
return (
<div className={classes}>
<span className="mx_NotificationBadge_count">{ symbol }</span>
</div>
);
return <StatelessNotificationBadge
label={label}
symbol={notification.symbol}
count={notification.count}
color={notification.color}
onClick={onClick}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>
{ tooltip }
</StatelessNotificationBadge>;
}
}

View File

@ -0,0 +1,81 @@
/*
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 React, { MouseEvent } from "react";
import classNames from "classnames";
import { formatCount } from "../../../../utils/FormattingUtils";
import AccessibleButton from "../../elements/AccessibleButton";
import { NotificationColor } from "../../../../stores/notifications/NotificationColor";
interface Props {
symbol: string | null;
count: number;
color: NotificationColor;
onClick?: (ev: MouseEvent) => void;
onMouseOver?: (ev: MouseEvent) => void;
onMouseLeave?: (ev: MouseEvent) => void;
children?: React.ReactChildren | JSX.Element;
label?: string;
}
export function StatelessNotificationBadge({
symbol,
count,
color,
...props }: Props) {
// Don't show a badge if we don't need to
if (color === NotificationColor.None) return null;
const hasUnreadCount = color >= NotificationColor.Grey && (!!count || !!symbol);
const isEmptyBadge = symbol === null && count === 0;
if (symbol === null && count > 0) {
symbol = formatCount(count);
}
const classes = classNames({
'mx_NotificationBadge': true,
'mx_NotificationBadge_visible': isEmptyBadge ? true : hasUnreadCount,
'mx_NotificationBadge_highlighted': color === NotificationColor.Red,
'mx_NotificationBadge_dot': isEmptyBadge,
'mx_NotificationBadge_2char': symbol?.length > 0 && symbol?.length < 3,
'mx_NotificationBadge_3char': symbol?.length > 2,
});
if (props.onClick) {
return (
<AccessibleButton
aria-label={props.label}
{...props}
className={classes}
onClick={props.onClick}
onMouseOver={props.onMouseOver}
onMouseLeave={props.onMouseLeave}
>
<span className="mx_NotificationBadge_count">{ symbol }</span>
{ props.children }
</AccessibleButton>
);
}
return (
<div className={classes}>
<span className="mx_NotificationBadge_count">{ symbol }</span>
</div>
);
}

View File

@ -0,0 +1,36 @@
/*
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 { Room } from "matrix-js-sdk/src/models/room";
import React from "react";
import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications";
import { StatelessNotificationBadge } from "./StatelessNotificationBadge";
interface Props {
room: Room;
threadId?: string;
}
export function UnreadNotificationBadge({ room, threadId }: Props) {
const { symbol, count, color } = useUnreadNotifications(room, threadId);
return <StatelessNotificationBadge
symbol={symbol}
count={count}
color={color}
/>;
}

View File

@ -263,9 +263,9 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
params: {
email: this.props.invitedEmail,
signurl: this.props.signUrl,
room_name: this.props.oobData ? this.props.oobData.room_name : null,
room_avatar_url: this.props.oobData ? this.props.oobData.avatarUrl : null,
inviter_name: this.props.oobData ? this.props.oobData.inviterName : null,
room_name: this.props.oobData?.name ?? null,
room_avatar_url: this.props.oobData?.avatarUrl ?? null,
inviter_name: this.props.oobData?.inviterName ?? null,
},
};
}

View File

@ -0,0 +1,93 @@
/*
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 { NotificationCount, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { useCallback, useEffect, useState } from "react";
import { getUnsentMessages } from "../components/structures/RoomStatusBar";
import { getRoomNotifsState, getUnreadNotificationCount, RoomNotifState } from "../RoomNotifs";
import { NotificationColor } from "../stores/notifications/NotificationColor";
import { doesRoomHaveUnreadMessages } from "../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../utils/membership";
import { useEventEmitter } from "./useEventEmitter";
export const useUnreadNotifications = (room: Room, threadId?: string): {
symbol: string | null;
count: number;
color: NotificationColor;
} => {
const [symbol, setSymbol] = useState<string | null>(null);
const [count, setCount] = useState<number>(0);
const [color, setColor] = useState<NotificationColor>(0);
useEventEmitter(room, RoomEvent.UnreadNotifications,
(unreadNotifications: NotificationCount, evtThreadId?: string) => {
// Discarding all events not related to the thread if one has been setup
if (threadId && threadId !== evtThreadId) return;
updateNotificationState();
},
);
useEventEmitter(room, RoomEvent.Receipt, () => updateNotificationState());
useEventEmitter(room, RoomEvent.Timeline, () => updateNotificationState());
useEventEmitter(room, RoomEvent.Redaction, () => updateNotificationState());
useEventEmitter(room, RoomEvent.LocalEchoUpdated, () => updateNotificationState());
useEventEmitter(room, RoomEvent.MyMembership, () => updateNotificationState());
const updateNotificationState = useCallback(() => {
if (getUnsentMessages(room, threadId).length > 0) {
setSymbol("!");
setCount(1);
setColor(NotificationColor.Unsent);
} else if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) {
setSymbol("!");
setCount(1);
setColor(NotificationColor.Red);
} else if (getRoomNotifsState(room.roomId) === RoomNotifState.Mute) {
setSymbol(null);
setCount(0);
setColor(NotificationColor.None);
} else {
const redNotifs = getUnreadNotificationCount(room, NotificationCountType.Highlight, threadId);
const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, threadId);
const trueCount = greyNotifs || redNotifs;
setCount(trueCount);
setSymbol(null);
if (redNotifs > 0) {
setColor(NotificationColor.Red);
} else if (greyNotifs > 0) {
setColor(NotificationColor.Grey);
} else if (!threadId) {
// TODO: No support for `Bold` on threads at the moment
// We don't have any notified messages, but we might have unread messages. Let's
// find out.
const hasUnread = doesRoomHaveUnreadMessages(room);
setColor(hasUnread ? NotificationColor.Bold : NotificationColor.None);
}
}
}, [room, threadId]);
useEffect(() => {
updateNotificationState();
}, [updateNotificationState]);
return {
symbol,
count,
color,
};
};

View File

@ -56,7 +56,7 @@ export interface IOOBData {
inviterName?: string; // The display name of the person who invited us to the room
// eslint-disable-next-line camelcase
room_name?: string; // The name of the room, to be used until we are told better by the server
roomType?: RoomType; // The type of the room, to be used until we are told better by the server
roomType?: RoomType | string; // The type of the room, to be used until we are told better by the server
}
const STORAGE_PREFIX = "mx_threepid_invite_";

View File

@ -17,6 +17,7 @@ limitations under the License.
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
@ -32,15 +33,16 @@ import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState";
export class RoomNotificationState extends NotificationState implements IDestroyable {
constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) {
super();
this.room.on(RoomEvent.Receipt, this.handleReadReceipt); // for unread indicators
this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); // for redness on invites
this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); // for redness on unsent messages
const cli = this.room.client;
this.room.on(RoomEvent.Receipt, this.handleReadReceipt);
this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate);
this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated);
this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts
if (threadsState) {
threadsState.on(NotificationStateEvents.Update, this.handleThreadsUpdate);
if (cli.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) {
this.threadsState?.on(NotificationStateEvents.Update, this.handleThreadsUpdate);
}
MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // for local count calculation
MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); // for push rules
cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
cli.on(ClientEvent.AccountData, this.handleAccountDataUpdate);
this.updateNotificationState();
}
@ -50,17 +52,17 @@ export class RoomNotificationState extends NotificationState implements IDestroy
public destroy(): void {
super.destroy();
const cli = this.room.client;
this.room.removeListener(RoomEvent.Receipt, this.handleReadReceipt);
this.room.removeListener(RoomEvent.MyMembership, this.handleMembershipUpdate);
this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated);
this.room.removeListener(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate);
if (this.threadsState) {
if (cli.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) {
this.room.removeListener(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate);
} else 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);
}
cli.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
cli.removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate);
}
private handleThreadsUpdate = () => {

View File

@ -17,6 +17,7 @@ limitations under the License.
import { Room } from "matrix-js-sdk/src/models/room";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { ActionPayload } from "../../dispatcher/payloads";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
@ -39,9 +40,9 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
instance.start();
return instance;
})();
private roomMap = new Map<Room, RoomNotificationState>();
private roomThreadsMap = new Map<Room, ThreadsRoomNotificationState>();
private roomThreadsMap: Map<Room, ThreadsRoomNotificationState> = new Map<Room, ThreadsRoomNotificationState>();
private listMap = new Map<TagID, ListNotificationState>();
private _globalState = new SummarizedNotificationState();
@ -86,18 +87,25 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
*/
public getRoomState(room: Room): RoomNotificationState {
if (!this.roomMap.has(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
const threadState = new ThreadsRoomNotificationState(room);
this.roomThreadsMap.set(room, threadState);
let threadState;
if (room.client.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) {
// 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
const threadState = new ThreadsRoomNotificationState(room);
this.roomThreadsMap.set(room, threadState);
}
this.roomMap.set(room, new RoomNotificationState(room, threadState));
}
return this.roomMap.get(room);
}
public getThreadsRoomState(room: Room): ThreadsRoomNotificationState {
public getThreadsRoomState(room: Room): ThreadsRoomNotificationState | null {
if (room.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) {
return null;
}
if (!this.roomThreadsMap.has(room)) {
this.roomThreadsMap.set(room, new ThreadsRoomNotificationState(room));
}

View File

@ -57,8 +57,8 @@ export function messageForResourceLimitError(
}
}
export function messageForSyncError(err: MatrixError): ReactNode {
if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
export function messageForSyncError(err: Error): ReactNode {
if (err instanceof MatrixError && err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const limitError = messageForResourceLimitError(
err.data.limit_type,
err.data.admin_contact,

View File

@ -139,5 +139,7 @@ export class VoiceBroadcastRecorder
}
export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => {
return new VoiceBroadcastRecorder(new VoiceRecording(), getChunkLength());
const voiceRecording = new VoiceRecording();
voiceRecording.disableMaxLength();
return new VoiceBroadcastRecorder(voiceRecording, getChunkLength());
};

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { Room, RoomMember, RoomType } from "matrix-js-sdk/src/matrix";
import { avatarUrlForRoom } from "../src/Avatar";
import { Media, mediaFromMxc } from "../src/customisations/Media";
@ -46,6 +46,7 @@ describe("avatarUrlForRoom", () => {
roomId,
getMxcAvatarUrl: jest.fn(),
isSpaceRoom: jest.fn(),
getType: jest.fn(),
getAvatarFallbackMember: jest.fn(),
} as unknown as Room;
dmRoomMap = {
@ -70,6 +71,7 @@ describe("avatarUrlForRoom", () => {
it("should return null for a space room", () => {
mocked(room.isSpaceRoom).mockReturnValue(true);
mocked(room.getType).mockReturnValue(RoomType.Space);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});

View File

@ -16,10 +16,15 @@ limitations under the License.
import { mocked } from 'jest-mock';
import { ConditionKind, PushRuleActionName, TweakName } from "matrix-js-sdk/src/@types/PushRules";
import { NotificationCountType, Room } from 'matrix-js-sdk/src/models/room';
import { stubClient } from "./test-utils";
import { mkEvent, stubClient } from "./test-utils";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import { getRoomNotifsState, RoomNotifState } from "../src/RoomNotifs";
import {
getRoomNotifsState,
RoomNotifState,
getUnreadNotificationCount,
} from "../src/RoomNotifs";
describe("RoomNotifs test", () => {
beforeEach(() => {
@ -83,4 +88,74 @@ describe("RoomNotifs test", () => {
});
expect(getRoomNotifsState("!roomId:server")).toBe(RoomNotifState.AllMessagesLoud);
});
describe("getUnreadNotificationCount", () => {
const ROOM_ID = "!roomId:example.org";
const THREAD_ID = "$threadId";
let cli;
let room: Room;
beforeEach(() => {
cli = MatrixClientPeg.get();
room = new Room(ROOM_ID, cli, cli.getUserId());
});
it("counts room notification type", () => {
expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(0);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(0);
});
it("counts notifications type", () => {
room.setUnreadNotificationCount(NotificationCountType.Total, 2);
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(2);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(1);
});
it("counts predecessor highlight", () => {
room.setUnreadNotificationCount(NotificationCountType.Total, 2);
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
const OLD_ROOM_ID = "!oldRoomId:example.org";
const oldRoom = new Room(OLD_ROOM_ID, cli, cli.getUserId());
oldRoom.setUnreadNotificationCount(NotificationCountType.Total, 10);
oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6);
cli.getRoom.mockReset().mockReturnValue(oldRoom);
const predecessorEvent = mkEvent({
event: true,
type: "m.room.create",
room: ROOM_ID,
user: cli.getUserId(),
content: {
creator: cli.getUserId(),
room_version: "5",
predecessor: {
room_id: OLD_ROOM_ID,
event_id: "$someevent",
},
},
ts: Date.now(),
});
room.addLiveEvents([predecessorEvent]);
expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(8);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(7);
});
it("counts thread notification type", () => {
expect(getUnreadNotificationCount(room, NotificationCountType.Total, THREAD_ID)).toBe(0);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(0);
});
it("counts notifications type", () => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 2);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1);
expect(getUnreadNotificationCount(room, NotificationCountType.Total, THREAD_ID)).toBe(2);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(1);
});
});
});

View File

@ -0,0 +1,105 @@
/*
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 { VoiceRecording } from "../../src/audio/VoiceRecording";
/**
* The tests here are heavily using access to private props.
* While this is not so great, we can at lest test some behaviour easily this way.
*/
describe("VoiceRecording", () => {
let recording: VoiceRecording;
let recorderSecondsSpy: jest.SpyInstance;
const itShouldNotCallStop = () => {
it("should not call stop", () => {
expect(recording.stop).not.toHaveBeenCalled();
});
};
const simulateUpdate = (recorderSeconds: number) => {
beforeEach(() => {
recorderSecondsSpy.mockReturnValue(recorderSeconds);
// @ts-ignore
recording.processAudioUpdate(recorderSeconds);
});
};
beforeEach(() => {
recording = new VoiceRecording();
// @ts-ignore
recording.observable = {
update: jest.fn(),
};
jest.spyOn(recording, "stop").mockImplementation();
recorderSecondsSpy = jest.spyOn(recording, "recorderSeconds", "get");
});
afterEach(() => {
jest.resetAllMocks();
});
describe("when recording", () => {
beforeEach(() => {
// @ts-ignore
recording.recording = true;
});
describe("and there is an audio update and time left", () => {
simulateUpdate(42);
itShouldNotCallStop();
});
describe("and there is an audio update and time is up", () => {
// one second above the limit
simulateUpdate(901);
it("should call stop", () => {
expect(recording.stop).toHaveBeenCalled();
});
});
describe("and the max length limit has been disabled", () => {
beforeEach(() => {
recording.disableMaxLength();
});
describe("and there is an audio update and time left", () => {
simulateUpdate(42);
itShouldNotCallStop();
});
describe("and there is an audio update and time is up", () => {
// one second above the limit
simulateUpdate(901);
itShouldNotCallStop();
});
});
});
describe("when not recording", () => {
describe("and there is an audio update and time left", () => {
simulateUpdate(42);
itShouldNotCallStop();
});
describe("and there is an audio update and time is up", () => {
// one second above the limit
simulateUpdate(901);
itShouldNotCallStop();
});
});
});

View File

@ -0,0 +1,91 @@
/*
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 { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { getUnsentMessages } from "../../../src/components/structures/RoomStatusBar";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { mkEvent, stubClient } from "../../test-utils/test-utils";
import { mkThread } from "../../test-utils/threads";
describe("RoomStatusBar", () => {
const ROOM_ID = "!roomId:example.org";
let room: Room;
let client: MatrixClient;
let event: MatrixEvent;
beforeEach(() => {
jest.clearAllMocks();
stubClient();
client = MatrixClientPeg.get();
room = new Room(ROOM_ID, client, client.getUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
event = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
event.status = EventStatus.NOT_SENT;
});
describe("getUnsentMessages", () => {
it("returns no unsent messages", () => {
expect(getUnsentMessages(room)).toHaveLength(0);
});
it("checks the event status", () => {
room.addPendingEvent(event, "123");
expect(getUnsentMessages(room)).toHaveLength(1);
event.status = EventStatus.SENT;
expect(getUnsentMessages(room)).toHaveLength(0);
});
it("only returns events related to a thread", () => {
room.addPendingEvent(event, "123");
const { rootEvent, events } = mkThread({
room,
client,
authorId: "@alice:example.org",
participantUserIds: ["@alice:example.org"],
length: 2,
});
rootEvent.status = EventStatus.NOT_SENT;
room.addPendingEvent(rootEvent, rootEvent.getId());
for (const event of events) {
event.status = EventStatus.NOT_SENT;
room.addPendingEvent(event, Date.now() + Math.random() + "");
}
const pendingEvents = getUnsentMessages(room, rootEvent.getId());
expect(pendingEvents[0].threadRootId).toBe(rootEvent.getId());
expect(pendingEvents[1].threadRootId).toBe(rootEvent.getId());
expect(pendingEvents[2].threadRootId).toBe(rootEvent.getId());
// Filters out the non thread events
expect(pendingEvents.every(ev => ev.getId() !== event.getId())).toBe(true);
});
});
});

View File

@ -15,8 +15,7 @@ limitations under the License.
*/
import React from 'react';
// eslint-disable-next-line deprecate/import
import { mount } from 'enzyme';
import { fireEvent, render } from "@testing-library/react";
import {
Beacon,
RoomMember,
@ -28,7 +27,6 @@ import { act } from 'react-dom/test-utils';
import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem';
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
import {
findByTestId,
getMockClientWithEventEmitter,
makeBeaconEvent,
makeBeaconInfoEvent,
@ -76,11 +74,9 @@ describe('<BeaconListItem />', () => {
beacon: new Beacon(aliceBeaconEvent),
};
const getComponent = (props = {}) =>
mount(<BeaconListItem {...defaultProps} {...props} />, {
wrappingComponent: MatrixClientContext.Provider,
wrappingComponentProps: { value: mockClient },
});
const getComponent = (props = {}) => render(<MatrixClientContext.Provider value={mockClient}>
<BeaconListItem {...defaultProps} {...props} />
</MatrixClientContext.Provider>);
const setupRoomWithBeacons = (beaconInfoEvents: MatrixEvent[], locationEvents?: MatrixEvent[]): Beacon[] => {
const beacons = makeRoomWithBeacons(roomId, mockClient, beaconInfoEvents, locationEvents);
@ -104,71 +100,72 @@ describe('<BeaconListItem />', () => {
{ isLive: false },
);
const [beacon] = setupRoomWithBeacons([notLiveBeacon]);
const component = getComponent({ beacon });
expect(component.html()).toBeNull();
const { container } = getComponent({ beacon });
expect(container.innerHTML).toBeFalsy();
});
it('renders null when beacon has no location', () => {
const [beacon] = setupRoomWithBeacons([aliceBeaconEvent]);
const component = getComponent({ beacon });
expect(component.html()).toBeNull();
const { container } = getComponent({ beacon });
expect(container.innerHTML).toBeFalsy();
});
describe('when a beacon is live and has locations', () => {
it('renders beacon info', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const component = getComponent({ beacon });
expect(component.html()).toMatchSnapshot();
const { asFragment } = getComponent({ beacon });
expect(asFragment()).toMatchSnapshot();
});
describe('non-self beacons', () => {
it('uses beacon description as beacon name', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const component = getComponent({ beacon });
expect(component.find('BeaconStatus').props().label).toEqual("Alice's car");
const { container } = getComponent({ beacon });
expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent("Alice's car");
});
it('uses beacon owner mxid as beacon name for a beacon without description', () => {
const [beacon] = setupRoomWithBeacons([pinBeaconWithoutDescription], [aliceLocation1]);
const component = getComponent({ beacon });
expect(component.find('BeaconStatus').props().label).toEqual(aliceId);
const { container } = getComponent({ beacon });
expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent(aliceId);
});
it('renders location icon', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const component = getComponent({ beacon });
expect(component.find('StyledLiveBeaconIcon').length).toBeTruthy();
const { container } = getComponent({ beacon });
expect(container.querySelector('.mx_StyledLiveBeaconIcon')).toBeTruthy();
});
});
describe('self locations', () => {
it('renders beacon owner avatar', () => {
const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]);
const component = getComponent({ beacon });
expect(component.find('MemberAvatar').length).toBeTruthy();
const { container } = getComponent({ beacon });
expect(container.querySelector('.mx_BaseAvatar')).toBeTruthy();
});
it('uses beacon owner name as beacon name', () => {
const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]);
const component = getComponent({ beacon });
expect(component.find('BeaconStatus').props().label).toEqual('Alice');
const { container } = getComponent({ beacon });
expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent("Alice");
});
});
describe('on location updates', () => {
it('updates last updated time on location updated', () => {
const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation2]);
const component = getComponent({ beacon });
const { container } = getComponent({ beacon });
expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated 9 minutes ago');
expect(container.querySelector('.mx_BeaconListItem_lastUpdated'))
.toHaveTextContent('Updated 9 minutes ago');
// update to a newer location
act(() => {
beacon.addLocations([aliceLocation1]);
component.setProps({});
});
expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago');
expect(container.querySelector('.mx_BeaconListItem_lastUpdated'))
.toHaveTextContent('Updated a few seconds ago');
});
});
@ -176,23 +173,19 @@ describe('<BeaconListItem />', () => {
it('does not call onClick handler when clicking share button', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const onClick = jest.fn();
const component = getComponent({ beacon, onClick });
const { getByTestId } = getComponent({ beacon, onClick });
act(() => {
findByTestId(component, 'open-location-in-osm').at(0).simulate('click');
});
fireEvent.click(getByTestId('open-location-in-osm'));
expect(onClick).not.toHaveBeenCalled();
});
it('calls onClick handler when clicking outside of share buttons', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const onClick = jest.fn();
const component = getComponent({ beacon, onClick });
const { container } = getComponent({ beacon, onClick });
act(() => {
// click the beacon name
component.find('.mx_BeaconStatus_description').simulate('click');
});
// click the beacon name
fireEvent.click(container.querySelector(".mx_BeaconStatus_description"));
expect(onClick).toHaveBeenCalled();
});
});

View File

@ -16,8 +16,7 @@ limitations under the License.
import React from 'react';
import { mocked } from 'jest-mock';
// eslint-disable-next-line deprecate/import
import { mount } from 'enzyme';
import { fireEvent, render } from "@testing-library/react";
import { act } from 'react-dom/test-utils';
import { Beacon, BeaconIdentifier } from 'matrix-js-sdk/src/matrix';
@ -48,9 +47,7 @@ jest.mock('../../../../src/stores/OwnBeaconStore', () => {
);
describe('<LeftPanelLiveShareWarning />', () => {
const defaultProps = {};
const getComponent = (props = {}) =>
mount(<LeftPanelLiveShareWarning {...defaultProps} {...props} />);
const getComponent = (props = {}) => render(<LeftPanelLiveShareWarning {...props} />);
const roomId1 = '!room1:server';
const roomId2 = '!room2:server';
@ -85,8 +82,8 @@ describe('<LeftPanelLiveShareWarning />', () => {
));
it('renders nothing when user has no live beacons', () => {
const component = getComponent();
expect(component.html()).toBe(null);
const { container } = getComponent();
expect(container.innerHTML).toBeFalsy();
});
describe('when user has live location monitor', () => {
@ -110,17 +107,15 @@ describe('<LeftPanelLiveShareWarning />', () => {
});
it('renders correctly when not minimized', () => {
const component = getComponent();
expect(component).toMatchSnapshot();
const { asFragment } = getComponent();
expect(asFragment()).toMatchSnapshot();
});
it('goes to room of latest beacon when clicked', () => {
const component = getComponent();
const { container } = getComponent();
const dispatchSpy = jest.spyOn(dispatcher, 'dispatch');
act(() => {
component.simulate('click');
});
fireEvent.click(container.querySelector("[role=button]"));
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
@ -134,28 +129,26 @@ describe('<LeftPanelLiveShareWarning />', () => {
});
it('renders correctly when minimized', () => {
const component = getComponent({ isMinimized: true });
expect(component).toMatchSnapshot();
const { asFragment } = getComponent({ isMinimized: true });
expect(asFragment()).toMatchSnapshot();
});
it('renders location publish error', () => {
mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue(
[beacon1.identifier],
);
const component = getComponent();
expect(component).toMatchSnapshot();
const { asFragment } = getComponent();
expect(asFragment()).toMatchSnapshot();
});
it('goes to room of latest beacon with location publish error when clicked', () => {
mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue(
[beacon1.identifier],
);
const component = getComponent();
const { container } = getComponent();
const dispatchSpy = jest.spyOn(dispatcher, 'dispatch');
act(() => {
component.simulate('click');
});
fireEvent.click(container.querySelector("[role=button]"));
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
@ -172,9 +165,9 @@ describe('<LeftPanelLiveShareWarning />', () => {
mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue(
[beacon1.identifier],
);
const component = getComponent();
const { container, rerender } = getComponent();
// error mode
expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual(
expect(container.querySelector('.mx_LeftPanelLiveShareWarning').textContent).toEqual(
'An error occurred whilst sharing your live location',
);
@ -183,18 +176,18 @@ describe('<LeftPanelLiveShareWarning />', () => {
OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.LocationPublishError, 'abc');
});
component.setProps({});
rerender(<LeftPanelLiveShareWarning />);
// default mode
expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual(
expect(container.querySelector('.mx_LeftPanelLiveShareWarning').textContent).toEqual(
'You are sharing your live location',
);
});
it('removes itself when user stops having live beacons', async () => {
const component = getComponent({ isMinimized: true });
const { container, rerender } = getComponent({ isMinimized: true });
// started out rendered
expect(component.html()).toBeTruthy();
expect(container.innerHTML).toBeTruthy();
act(() => {
mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false;
@ -202,9 +195,9 @@ describe('<LeftPanelLiveShareWarning />', () => {
});
await flushPromises();
component.setProps({});
rerender(<LeftPanelLiveShareWarning />);
expect(component.html()).toBe(null);
expect(container.innerHTML).toBeFalsy();
});
it('refreshes beacon liveness monitors when pagevisibilty changes to visible', () => {
@ -228,21 +221,21 @@ describe('<LeftPanelLiveShareWarning />', () => {
describe('stopping errors', () => {
it('renders stopping error', () => {
OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error'));
const component = getComponent();
expect(component.text()).toEqual('An error occurred while stopping your live location');
const { container } = getComponent();
expect(container.textContent).toEqual('An error occurred while stopping your live location');
});
it('starts rendering stopping error on beaconUpdateError emit', () => {
const component = getComponent();
const { container } = getComponent();
// no error
expect(component.text()).toEqual('You are sharing your live location');
expect(container.textContent).toEqual('You are sharing your live location');
act(() => {
OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error'));
OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.BeaconUpdateError, beacon2.identifier, true);
});
expect(component.text()).toEqual('An error occurred while stopping your live location');
expect(container.textContent).toEqual('An error occurred while stopping your live location');
});
it('renders stopping error when beacons have stopping and location errors', () => {
@ -250,8 +243,8 @@ describe('<LeftPanelLiveShareWarning />', () => {
[beacon1.identifier],
);
OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error'));
const component = getComponent();
expect(component.text()).toEqual('An error occurred while stopping your live location');
const { container } = getComponent();
expect(container.textContent).toEqual('An error occurred while stopping your live location');
});
it('goes to room of latest beacon with stopping error when clicked', () => {
@ -259,12 +252,10 @@ describe('<LeftPanelLiveShareWarning />', () => {
[beacon1.identifier],
);
OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error'));
const component = getComponent();
const { container } = getComponent();
const dispatchSpy = jest.spyOn(dispatcher, 'dispatch');
act(() => {
component.simulate('click');
});
fireEvent.click(container.querySelector("[role=button]"));
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,

View File

@ -15,9 +15,7 @@ limitations under the License.
*/
import React from 'react';
// eslint-disable-next-line deprecate/import
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { fireEvent, render } from "@testing-library/react";
import ShareLatestLocation from '../../../../src/components/views/beacon/ShareLatestLocation';
import { copyPlaintext } from '../../../../src/utils/strings';
@ -34,26 +32,23 @@ describe('<ShareLatestLocation />', () => {
timestamp: 123,
},
};
const getComponent = (props = {}) =>
mount(<ShareLatestLocation {...defaultProps} {...props} />);
const getComponent = (props = {}) => render(<ShareLatestLocation {...defaultProps} {...props} />);
beforeEach(() => {
jest.clearAllMocks();
});
it('renders null when no location', () => {
const component = getComponent({ latestLocationState: undefined });
expect(component.html()).toBeNull();
const { container } = getComponent({ latestLocationState: undefined });
expect(container.innerHTML).toBeFalsy();
});
it('renders share buttons when there is a location', async () => {
const component = getComponent();
expect(component).toMatchSnapshot();
const { container, asFragment } = getComponent();
expect(asFragment()).toMatchSnapshot();
await act(async () => {
component.find('.mx_CopyableText_copyButton').at(0).simulate('click');
await flushPromises();
});
fireEvent.click(container.querySelector('.mx_CopyableText_copyButton'));
await flushPromises();
expect(copyPlaintext).toHaveBeenCalledWith('51,42');
});

View File

@ -15,18 +15,16 @@ limitations under the License.
*/
import React from 'react';
// eslint-disable-next-line deprecate/import
import { mount } from 'enzyme';
import { render } from "@testing-library/react";
import StyledLiveBeaconIcon from '../../../../src/components/views/beacon/StyledLiveBeaconIcon';
describe('<StyledLiveBeaconIcon />', () => {
const defaultProps = {};
const getComponent = (props = {}) =>
mount(<StyledLiveBeaconIcon {...defaultProps} {...props} />);
const getComponent = (props = {}) => render(<StyledLiveBeaconIcon {...defaultProps} {...props} />);
it('renders', () => {
const component = getComponent();
expect(component).toBeTruthy();
const { asFragment } = getComponent();
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -1,3 +1,68 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<BeaconListItem /> when a beacon is live and has locations renders beacon info 1`] = `"<li class=\\"mx_BeaconListItem\\"><div class=\\"mx_StyledLiveBeaconIcon mx_BeaconListItem_avatarIcon\\"></div><div class=\\"mx_BeaconListItem_info\\"><div class=\\"mx_BeaconStatus mx_BeaconStatus_Active mx_BeaconListItem_status\\"><div class=\\"mx_BeaconStatus_description\\"><span class=\\"mx_BeaconStatus_label\\">Alice's car</span><span class=\\"mx_BeaconStatus_expiryTime\\">Live until 16:04</span></div><div class=\\"mx_BeaconListItem_interactions\\"><div tabindex=\\"0\\"><a data-test-id=\\"open-location-in-osm\\" href=\\"https://www.openstreetmap.org/?mlat=51&amp;mlon=41#map=16/51/41\\" target=\\"_blank\\" rel=\\"noreferrer noopener\\"><div class=\\"mx_ShareLatestLocation_icon\\"></div></a></div><div class=\\"mx_CopyableText mx_ShareLatestLocation_copy\\"><div aria-label=\\"Copy\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_CopyableText_copyButton\\"></div></div></div></div><span class=\\"mx_BeaconListItem_lastUpdated\\">Updated a few seconds ago</span></div></li>"`;
exports[`<BeaconListItem /> when a beacon is live and has locations renders beacon info 1`] = `
<DocumentFragment>
<li
class="mx_BeaconListItem"
>
<div
class="mx_StyledLiveBeaconIcon mx_BeaconListItem_avatarIcon"
/>
<div
class="mx_BeaconListItem_info"
>
<div
class="mx_BeaconStatus mx_BeaconStatus_Active mx_BeaconListItem_status"
>
<div
class="mx_BeaconStatus_description"
>
<span
class="mx_BeaconStatus_label"
>
Alice's car
</span>
<span
class="mx_BeaconStatus_expiryTime"
>
Live until 16:04
</span>
</div>
<div
class="mx_BeaconListItem_interactions"
>
<div
tabindex="0"
>
<a
data-testid="open-location-in-osm"
href="https://www.openstreetmap.org/?mlat=51&mlon=41#map=16/51/41"
rel="noreferrer noopener"
target="_blank"
>
<div
class="mx_ShareLatestLocation_icon"
/>
</a>
</div>
<div
class="mx_CopyableText mx_ShareLatestLocation_copy"
>
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</div>
</div>
<span
class="mx_BeaconListItem_lastUpdated"
>
Updated a few seconds ago
</span>
</div>
</li>
</DocumentFragment>
`;

View File

@ -75,7 +75,7 @@ exports[`<DialogSidebar /> renders sidebar correctly with beacons 1`] = `
tabindex="0"
>
<a
data-test-id="open-location-in-osm"
data-testid="open-location-in-osm"
href="https://www.openstreetmap.org/?mlat=51&mlon=41#map=16/51/41"
rel="noreferrer noopener"
target="_blank"

View File

@ -1,76 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LeftPanelLiveShareWarning /> when user has live location monitor renders correctly when minimized 1`] = `
<LeftPanelLiveShareWarning
isMinimized={true}
>
<AccessibleButton
className="mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__minimized"
element="div"
onClick={[Function]}
<DocumentFragment>
<div
class="mx_AccessibleButton mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__minimized"
role="button"
tabIndex={0}
tabindex="0"
title="You are sharing your live location"
>
<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>
height="10"
/>
</div>
</DocumentFragment>
`;
exports[`<LeftPanelLiveShareWarning /> when user has live location monitor renders correctly when not minimized 1`] = `
<LeftPanelLiveShareWarning>
<AccessibleButton
className="mx_LeftPanelLiveShareWarning"
element="div"
onClick={[Function]}
<DocumentFragment>
<div
class="mx_AccessibleButton mx_LeftPanelLiveShareWarning"
role="button"
tabIndex={0}
tabindex="0"
>
<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>
You are sharing your live location
</div>
</DocumentFragment>
`;
exports[`<LeftPanelLiveShareWarning /> when user has live location monitor renders location publish error 1`] = `
<LeftPanelLiveShareWarning>
<AccessibleButton
className="mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__error"
element="div"
onClick={[Function]}
<DocumentFragment>
<div
class="mx_AccessibleButton mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__error"
role="button"
tabIndex={0}
tabindex="0"
>
<div
className="mx_AccessibleButton mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__error"
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
>
An error occurred whilst sharing your live location
</div>
</AccessibleButton>
</LeftPanelLiveShareWarning>
An error occurred whilst sharing your live location
</div>
</DocumentFragment>
`;

View File

@ -1,79 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ShareLatestLocation /> renders share buttons when there is a location 1`] = `
<ShareLatestLocation
latestLocationState={
Object {
"timestamp": 123,
"uri": "geo:51,42;u=35",
}
}
>
<TooltipTarget
label="Open in OpenStreetMap"
<DocumentFragment>
<div
tabindex="0"
>
<a
data-testid="open-location-in-osm"
href="https://www.openstreetmap.org/?mlat=51&mlon=42#map=16/51/42"
rel="noreferrer noopener"
target="_blank"
>
<div
class="mx_ShareLatestLocation_icon"
/>
</a>
</div>
<div
class="mx_CopyableText mx_ShareLatestLocation_copy"
>
<div
onBlur={[Function]}
onFocus={[Function]}
onMouseLeave={[Function]}
onMouseMove={[Function]}
onMouseOver={[Function]}
tabIndex={0}
>
<a
data-test-id="open-location-in-osm"
href="https://www.openstreetmap.org/?mlat=51&mlon=42#map=16/51/42"
rel="noreferrer noopener"
target="_blank"
>
<div
className="mx_ShareLatestLocation_icon"
/>
</a>
</div>
</TooltipTarget>
<CopyableText
border={false}
className="mx_ShareLatestLocation_copy"
getTextToCopy={[Function]}
>
<div
className="mx_CopyableText mx_ShareLatestLocation_copy"
>
<AccessibleTooltipButton
className="mx_CopyableText_copyButton"
onClick={[Function]}
onHideTooltip={[Function]}
title="Copy"
>
<AccessibleButton
aria-label="Copy"
className="mx_CopyableText_copyButton"
element="div"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseLeave={[Function]}
onMouseOver={[Function]}
role="button"
tabIndex={0}
>
<div
aria-label="Copy"
className="mx_AccessibleButton mx_CopyableText_copyButton"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseLeave={[Function]}
onMouseOver={[Function]}
role="button"
tabIndex={0}
/>
</AccessibleButton>
</AccessibleTooltipButton>
</div>
</CopyableText>
</ShareLatestLocation>
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</DocumentFragment>
`;

View File

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<StyledLiveBeaconIcon /> renders 1`] = `
<DocumentFragment>
<div
class="mx_StyledLiveBeaconIcon"
/>
</DocumentFragment>
`;

View File

@ -16,6 +16,7 @@ limitations under the License.
import React from "react";
import { render, screen } from "@testing-library/react";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import InviteDialog from "../../../../src/components/views/dialogs/InviteDialog";
import { KIND_INVITE } from "../../../../src/components/views/dialogs/InviteDialogTypes";
@ -74,6 +75,7 @@ describe("InviteDialog", () => {
it("should label with space name", () => {
mockClient.getRoom(roomId).isSpaceRoom = jest.fn().mockReturnValue(true);
mockClient.getRoom(roomId).getType = jest.fn().mockReturnValue(RoomType.Space);
mockClient.getRoom(roomId).name = "Space";
render((
<InviteDialog

View File

@ -15,9 +15,7 @@ limitations under the License.
*/
import React from 'react';
// eslint-disable-next-line deprecate/import
import { mount } from 'enzyme';
import { act } from "react-dom/test-utils";
import { fireEvent, render } from "@testing-library/react";
import StyledRadioGroup from "../../../../src/components/views/elements/StyledRadioGroup";
@ -44,16 +42,16 @@ describe('<StyledRadioGroup />', () => {
definitions: defaultDefinitions,
onChange: jest.fn(),
};
const getComponent = (props = {}) => mount(<StyledRadioGroup {...defaultProps} {...props} />);
const getComponent = (props = {}) => render(<StyledRadioGroup {...defaultProps} {...props} />);
const getInputByValue = (component, value) => component.find(`input[value="${value}"]`);
const getCheckedInput = component => component.find('input[checked=true]');
const getInputByValue = (component, value) => component.container.querySelector(`input[value="${value}"]`);
const getCheckedInput = component => component.container.querySelector('input[checked]');
it('renders radios correctly when no value is provided', () => {
const component = getComponent();
expect(component).toMatchSnapshot();
expect(getCheckedInput(component).length).toBeFalsy();
expect(component.asFragment()).toMatchSnapshot();
expect(getCheckedInput(component)).toBeFalsy();
});
it('selects correct button when value is provided', () => {
@ -61,7 +59,7 @@ describe('<StyledRadioGroup />', () => {
value: optionC.value,
});
expect(getCheckedInput(component).at(0).props().value).toEqual(optionC.value);
expect(getCheckedInput(component).value).toEqual(optionC.value);
});
it('selects correct buttons when definitions have checked prop', () => {
@ -74,10 +72,10 @@ describe('<StyledRadioGroup />', () => {
value: optionC.value, definitions,
});
expect(getInputByValue(component, optionA.value).props().checked).toBeTruthy();
expect(getInputByValue(component, optionB.value).props().checked).toBeFalsy();
expect(getInputByValue(component, optionA.value)).toBeChecked();
expect(getInputByValue(component, optionB.value)).not.toBeChecked();
// optionC.checked = false overrides value matching
expect(getInputByValue(component, optionC.value).props().checked).toBeFalsy();
expect(getInputByValue(component, optionC.value)).not.toBeChecked();
});
it('disables individual buttons based on definition.disabled', () => {
@ -87,16 +85,16 @@ describe('<StyledRadioGroup />', () => {
{ ...optionC, disabled: true },
];
const component = getComponent({ definitions });
expect(getInputByValue(component, optionA.value).props().disabled).toBeFalsy();
expect(getInputByValue(component, optionB.value).props().disabled).toBeTruthy();
expect(getInputByValue(component, optionC.value).props().disabled).toBeTruthy();
expect(getInputByValue(component, optionA.value)).not.toBeDisabled();
expect(getInputByValue(component, optionB.value)).toBeDisabled();
expect(getInputByValue(component, optionC.value)).toBeDisabled();
});
it('disables all buttons with disabled prop', () => {
const component = getComponent({ disabled: true });
expect(getInputByValue(component, optionA.value).props().disabled).toBeTruthy();
expect(getInputByValue(component, optionB.value).props().disabled).toBeTruthy();
expect(getInputByValue(component, optionC.value).props().disabled).toBeTruthy();
expect(getInputByValue(component, optionA.value)).toBeDisabled();
expect(getInputByValue(component, optionB.value)).toBeDisabled();
expect(getInputByValue(component, optionC.value)).toBeDisabled();
});
it('calls onChange on click', () => {
@ -106,9 +104,7 @@ describe('<StyledRadioGroup />', () => {
onChange,
});
act(() => {
getInputByValue(component, optionB.value).simulate('change');
});
fireEvent.click(getInputByValue(component, optionB.value));
expect(onChange).toHaveBeenCalledWith(optionB.value);
});

View File

@ -1,152 +1,83 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<StyledRadioGroup /> renders radios correctly when no value is provided 1`] = `
<StyledRadioGroup
className="test-class"
definitions={
Array [
Object {
"className": "a-class",
"description": "anteater description",
"label": <span>
Anteater label
</span>,
"value": "Anteater",
},
Object {
"label": <span>
Badger label
</span>,
"value": "Badger",
},
Object {
"description": <span>
Canary description
</span>,
"label": <span>
Canary label
</span>,
"value": "Canary",
},
]
}
name="test"
onChange={[MockFunction]}
>
<StyledRadioButton
aria-describedby="test-Anteater-description"
checked={false}
childrenInLabel={true}
className="test-class a-class"
id="test-Anteater"
name="test"
onChange={[Function]}
value="Anteater"
<DocumentFragment>
<label
class="mx_StyledRadioButton test-class a-class mx_StyledRadioButton_enabled"
>
<label
className="mx_StyledRadioButton test-class a-class mx_StyledRadioButton_enabled"
<input
aria-describedby="test-Anteater-description"
id="test-Anteater"
name="test"
type="radio"
value="Anteater"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
<input
aria-describedby="test-Anteater-description"
checked={false}
id="test-Anteater"
name="test"
onChange={[Function]}
type="radio"
value="Anteater"
/>
<div>
<div />
</div>
<div
className="mx_StyledRadioButton_content"
>
<span>
Anteater label
</span>
</div>
<div
className="mx_StyledRadioButton_spacer"
/>
</label>
</StyledRadioButton>
<span>
Anteater label
</span>
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
<span
id="test-Anteater-description"
>
anteater description
</span>
<StyledRadioButton
checked={false}
childrenInLabel={true}
className="test-class"
id="test-Badger"
name="test"
onChange={[Function]}
value="Badger"
<label
class="mx_StyledRadioButton test-class mx_StyledRadioButton_enabled"
>
<label
className="mx_StyledRadioButton test-class mx_StyledRadioButton_enabled"
<input
id="test-Badger"
name="test"
type="radio"
value="Badger"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
<input
checked={false}
id="test-Badger"
name="test"
onChange={[Function]}
type="radio"
value="Badger"
/>
<div>
<div />
</div>
<div
className="mx_StyledRadioButton_content"
>
<span>
Badger label
</span>
</div>
<div
className="mx_StyledRadioButton_spacer"
/>
</label>
</StyledRadioButton>
<StyledRadioButton
aria-describedby="test-Canary-description"
checked={false}
childrenInLabel={true}
className="test-class"
id="test-Canary"
name="test"
onChange={[Function]}
value="Canary"
<span>
Badger label
</span>
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
<label
class="mx_StyledRadioButton test-class mx_StyledRadioButton_enabled"
>
<label
className="mx_StyledRadioButton test-class mx_StyledRadioButton_enabled"
<input
aria-describedby="test-Canary-description"
id="test-Canary"
name="test"
type="radio"
value="Canary"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
<input
aria-describedby="test-Canary-description"
checked={false}
id="test-Canary"
name="test"
onChange={[Function]}
type="radio"
value="Canary"
/>
<div>
<div />
</div>
<div
className="mx_StyledRadioButton_content"
>
<span>
Canary label
</span>
</div>
<div
className="mx_StyledRadioButton_spacer"
/>
</label>
</StyledRadioButton>
<span>
Canary label
</span>
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
<span
id="test-Canary-description"
>
@ -154,5 +85,5 @@ exports[`<StyledRadioGroup /> renders radios correctly when no value is provided
Canary description
</span>
</span>
</StyledRadioGroup>
</DocumentFragment>
`;

View File

@ -0,0 +1,97 @@
/*
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 { render } from "@testing-library/react";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import React from "react";
import RoomHeaderButtons from "../../../../src/components/views/right_panel/RoomHeaderButtons";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { stubClient } from "../../../test-utils";
describe("RoomHeaderButtons-test.tsx", function() {
const ROOM_ID = "!roomId:example.org";
let room: Room;
let client: MatrixClient;
beforeEach(() => {
jest.clearAllMocks();
stubClient();
client = MatrixClientPeg.get();
room = new Room(ROOM_ID, client, client.getUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
if (name === "feature_thread") return true;
});
});
function getComponent(room: Room) {
return render(<RoomHeaderButtons
room={room}
excludedRightPanelPhaseButtons={[]}
/>);
}
function getThreadButton(container) {
return container.querySelector(".mx_RightPanel_threadsButton");
}
function isIndicatorOfType(container, type: "red" | "gray") {
return container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")
.className
.includes(type);
}
it("shows the thread button", () => {
const { container } = getComponent(room);
expect(getThreadButton(container)).not.toBeNull();
});
it("hides the thread button", () => {
jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false);
const { container } = getComponent(room);
expect(getThreadButton(container)).toBeNull();
});
it("room wide notification does not change the thread button", () => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
const { container } = getComponent(room);
expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull();
});
it("room wide notification does not change the thread button", () => {
const { container } = getComponent(room);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1);
expect(isIndicatorOfType(container, "gray")).toBe(true);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);
expect(isIndicatorOfType(container, "red")).toBe(true);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 0);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 0);
expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull();
});
});

View File

@ -140,6 +140,7 @@ describe('<UserInfo />', () => {
describe('with a room', () => {
const room = {
roomId: '!fkfk',
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue('mock-avatar-url'),

View File

@ -0,0 +1,112 @@
/*
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 { act, render } from "@testing-library/react";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import React from "react";
import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile";
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { getRoomContext, mkMessage, stubClient } from "../../../test-utils";
import { mkThread } from "../../../test-utils/threads";
describe("EventTile", () => {
const ROOM_ID = "!roomId:example.org";
let mxEvent: MatrixEvent;
let room: Room;
let client: MatrixClient;
// let changeEvent: (event: MatrixEvent) => void;
function TestEventTile(props: Partial<EventTileProps>) {
// const [event] = useState(mxEvent);
// Give a way for a test to update the event prop.
// changeEvent = setEvent;
return <EventTile
mxEvent={mxEvent}
{...props}
/>;
}
function getComponent(
overrides: Partial<EventTileProps> = {},
renderingType: TimelineRenderingType = TimelineRenderingType.Room,
) {
const context = getRoomContext(room, {
timelineRenderingType: renderingType,
});
return render(
<RoomContext.Provider value={context}>
<TestEventTile {...overrides} />
</RoomContext.Provider>,
);
}
beforeEach(() => {
jest.clearAllMocks();
stubClient();
client = MatrixClientPeg.get();
room = new Room(ROOM_ID, client, client.getUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
mxEvent = mkMessage({
room: room.roomId,
user: "@alice:example.org",
msg: "Hello world!",
event: true,
});
});
describe("EventTile renderingType: ThreadsList", () => {
beforeEach(() => {
const { rootEvent } = mkThread({
room,
client,
authorId: "@alice:example.org",
participantUserIds: ["@alice:example.org"],
});
mxEvent = rootEvent;
});
it("shows an unread notification bage", () => {
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0);
act(() => {
room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Total, 3);
});
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(0);
act(() => {
room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Highlight, 1);
});
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(1);
});
});
});

View File

@ -0,0 +1,49 @@
/*
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 { fireEvent, render } from "@testing-library/react";
import React from "react";
import {
StatelessNotificationBadge,
} from "../../../../../src/components/views/rooms/NotificationBadge/StatelessNotificationBadge";
import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor";
describe("NotificationBadge", () => {
describe("StatelessNotificationBadge", () => {
it("lets you click it", () => {
const cb = jest.fn();
const { container } = render(<StatelessNotificationBadge
symbol=""
color={NotificationColor.Red}
count={5}
onClick={cb}
onMouseOver={cb}
onMouseLeave={cb}
/>);
fireEvent.click(container.firstChild);
expect(cb).toHaveBeenCalledTimes(1);
fireEvent.mouseEnter(container.firstChild);
expect(cb).toHaveBeenCalledTimes(2);
fireEvent.mouseLeave(container.firstChild);
expect(cb).toHaveBeenCalledTimes(3);
});
});
});

View File

@ -0,0 +1,132 @@
/*
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 React from "react";
import "jest-mock";
import { screen, act, render } from "@testing-library/react";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { mocked } from "jest-mock";
import { EventStatus } from "matrix-js-sdk/src/models/event-status";
import {
UnreadNotificationBadge,
} from "../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge";
import { mkMessage, stubClient } from "../../../../test-utils/test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import * as RoomNotifs from "../../../../../src/RoomNotifs";
jest.mock("../../../../../src/RoomNotifs");
jest.mock('../../../../../src/RoomNotifs', () => ({
...(jest.requireActual('../../../../../src/RoomNotifs') as Object),
getRoomNotifsState: jest.fn(),
}));
const ROOM_ID = "!roomId:example.org";
let THREAD_ID;
describe("UnreadNotificationBadge", () => {
let mockClient: MatrixClient;
let room: Room;
function getComponent(threadId?: string) {
return <UnreadNotificationBadge room={room} threadId={threadId} />;
}
beforeEach(() => {
jest.clearAllMocks();
stubClient();
mockClient = mocked(MatrixClientPeg.get());
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
jest.spyOn(RoomNotifs, "getRoomNotifsState").mockReturnValue(RoomNotifs.RoomNotifState.AllMessages);
});
it("renders unread notification badge", () => {
const { container } = render(getComponent());
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeFalsy();
act(() => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
});
expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeTruthy();
});
it("renders unread thread notification badge", () => {
const { container } = render(getComponent(THREAD_ID));
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeFalsy();
act(() => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1);
});
expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeTruthy();
});
it("hides unread notification badge", () => {
act(() => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
const { container } = render(getComponent(THREAD_ID));
expect(container.querySelector(".mx_NotificationBadge_visible")).toBeFalsy();
});
});
it("adds a warning for unsent messages", () => {
const evt = mkMessage({
room: room.roomId,
user: "@alice:example.org",
msg: "Hello world!",
event: true,
});
evt.status = EventStatus.NOT_SENT;
room.addPendingEvent(evt, "123");
render(getComponent());
expect(screen.queryByText("!")).not.toBeNull();
});
it("adds a warning for invites", () => {
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
render(getComponent());
expect(screen.queryByText("!")).not.toBeNull();
});
it("hides counter for muted rooms", () => {
jest.spyOn(RoomNotifs, "getRoomNotifsState")
.mockReset()
.mockReturnValue(RoomNotifs.RoomNotifState.Mute);
const { container } = render(getComponent());
expect(container.querySelector(".mx_NotificationBadge")).toBeNull();
});
});

View File

@ -15,18 +15,14 @@ limitations under the License.
*/
import React from 'react';
import {
renderIntoDocument,
Simulate,
findRenderedDOMComponentWithClass,
act,
} from 'react-dom/test-utils';
import { render, fireEvent, RenderResult, waitFor } from "@testing-library/react";
import { Room, RoomMember, MatrixError, IContent } from 'matrix-js-sdk/src/matrix';
import { stubClient } from '../../../test-utils';
import { MatrixClientPeg } from '../../../../src/MatrixClientPeg';
import DMRoomMap from '../../../../src/utils/DMRoomMap';
import RoomPreviewBar from '../../../../src/components/views/rooms/RoomPreviewBar';
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
jest.mock('../../../../src/IdentityAuthClient', () => {
return jest.fn().mockImplementation(() => {
@ -79,19 +75,18 @@ describe('<RoomPreviewBar />', () => {
const defaultProps = {
room: createRoom(roomId, userId),
};
const wrapper = renderIntoDocument<React.Component>(
<RoomPreviewBar {...defaultProps} {...props} />,
) as React.Component;
return findRenderedDOMComponentWithClass(wrapper, 'mx_RoomPreviewBar') as HTMLDivElement;
return render(<RoomPreviewBar {...defaultProps} {...props} />);
};
const isSpinnerRendered = (element: Element) => !!element.querySelector('.mx_Spinner');
const getMessage = (element: Element) => element.querySelector<HTMLDivElement>('.mx_RoomPreviewBar_message');
const getActions = (element: Element) => element.querySelector<HTMLDivElement>('.mx_RoomPreviewBar_actions');
const getPrimaryActionButton = (element: Element) =>
getActions(element).querySelector('.mx_AccessibleButton_kind_primary');
const getSecondaryActionButton = (element: Element) =>
getActions(element).querySelector('.mx_AccessibleButton_kind_secondary');
const isSpinnerRendered = (wrapper: RenderResult) => !!wrapper.container.querySelector('.mx_Spinner');
const getMessage = (wrapper: RenderResult) =>
wrapper.container.querySelector<HTMLDivElement>('.mx_RoomPreviewBar_message');
const getActions = (wrapper: RenderResult) =>
wrapper.container.querySelector<HTMLDivElement>('.mx_RoomPreviewBar_actions');
const getPrimaryActionButton = (wrapper: RenderResult) =>
getActions(wrapper).querySelector('.mx_AccessibleButton_kind_primary');
const getSecondaryActionButton = (wrapper: RenderResult) =>
getActions(wrapper).querySelector('.mx_AccessibleButton_kind_secondary');
beforeEach(() => {
stubClient();
@ -128,6 +123,36 @@ describe('<RoomPreviewBar />', () => {
expect(getMessage(component).textContent).toEqual('Join the conversation with an account');
});
it("should send room oob data to start login", async () => {
MatrixClientPeg.get().isGuest = jest.fn().mockReturnValue(true);
const component = getComponent({
oobData: {
name: "Room Name",
avatarUrl: "mxc://foo/bar",
inviterName: "Charlie",
},
});
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
expect(getMessage(component).textContent).toEqual('Join the conversation with an account');
fireEvent.click(getPrimaryActionButton(component));
await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({
screenAfterLogin: {
screen: 'room',
params: expect.objectContaining({
room_name: "Room Name",
room_avatar_url: "mxc://foo/bar",
inviter_name: "Charlie",
}),
},
})));
defaultDispatcher.unregister(dispatcherRef);
});
it('renders kicked message', () => {
const room = createRoom(roomId, otherUserId);
jest.spyOn(room, 'getMember').mockReturnValue(makeMockRoomMember({ isKicked: true }));
@ -233,18 +258,14 @@ describe('<RoomPreviewBar />', () => {
it('joins room on primary button click', () => {
const component = getComponent({ inviterName, room, onJoinClick, onRejectClick });
act(() => {
Simulate.click(getPrimaryActionButton(component));
});
fireEvent.click(getPrimaryActionButton(component));
expect(onJoinClick).toHaveBeenCalled();
});
it('rejects invite on secondary button click', () => {
const component = getComponent({ inviterName, room, onJoinClick, onRejectClick });
act(() => {
Simulate.click(getSecondaryActionButton(component));
});
fireEvent.click(getSecondaryActionButton(component));
expect(onRejectClick).toHaveBeenCalled();
});
@ -296,9 +317,7 @@ describe('<RoomPreviewBar />', () => {
await new Promise(setImmediate);
expect(getPrimaryActionButton(component)).toBeTruthy();
expect(getSecondaryActionButton(component)).toBeFalsy();
act(() => {
Simulate.click(getPrimaryActionButton(component));
});
fireEvent.click(getPrimaryActionButton(component));
expect(onJoinClick).toHaveBeenCalled();
};

View File

@ -15,8 +15,7 @@ limitations under the License.
*/
import React from "react";
// eslint-disable-next-line deprecate/import
import { mount } from "enzyme";
import { render } from "@testing-library/react";
import { TextInputField } from "@matrix-org/react-sdk-module-api/lib/components/TextInputField";
import { Spinner as ModuleSpinner } from "@matrix-org/react-sdk-module-api/lib/components/Spinner";
@ -31,12 +30,12 @@ describe("Module Components", () => {
// ModuleRunner import to do its job (as per documentation in ModuleComponents).
it("should override the factory for a TextInputField", () => {
const component = mount(<TextInputField label="My Label" value="My Value" onChange={() => {}} />);
expect(component).toMatchSnapshot();
const { asFragment } = render(<TextInputField label="My Label" value="My Value" onChange={() => {}} />);
expect(asFragment()).toMatchSnapshot();
});
it("should override the factory for a ModuleSpinner", () => {
const component = mount(<ModuleSpinner />);
expect(component).toMatchSnapshot();
const { asFragment } = render(<ModuleSpinner />);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -1,68 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Module Components should override the factory for a ModuleSpinner 1`] = `
<Spinner>
<Spinner
h={32}
w={32}
<DocumentFragment>
<div
class="mx_Spinner"
>
<div
className="mx_Spinner"
>
<div
aria-label="Loading..."
className="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style={
Object {
"height": 32,
"width": 32,
}
}
/>
</div>
</Spinner>
</Spinner>
aria-label="Loading..."
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</DocumentFragment>
`;
exports[`Module Components should override the factory for a TextInputField 1`] = `
<TextInputField
label="My Label"
onChange={[Function]}
value="My Value"
>
<Field
autoComplete="off"
element="input"
label="My Label"
onChange={[Function]}
type="text"
validateOnBlur={true}
validateOnChange={true}
validateOnFocus={true}
value="My Value"
<DocumentFragment>
<div
class="mx_Field mx_Field_input"
>
<div
className="mx_Field mx_Field_input"
<input
autocomplete="off"
id="mx_Field_1"
label="My Label"
placeholder="My Label"
type="text"
value="My Value"
/>
<label
for="mx_Field_1"
>
<input
autoComplete="off"
id="mx_Field_1"
label="My Label"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="My Label"
type="text"
value="My Value"
/>
<label
htmlFor="mx_Field_1"
>
My Label
</label>
</div>
</Field>
</TextInputField>
My Label
</label>
</div>
</DocumentFragment>
`;

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEventEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MatrixEventEvent, MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
import { stubClient } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
@ -24,12 +24,16 @@ import * as testUtils from "../../test-utils";
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
describe("RoomNotificationState", () => {
stubClient();
const client = MatrixClientPeg.get();
let testRoom: Room;
let client: MatrixClient;
beforeEach(() => {
stubClient();
client = MatrixClientPeg.get();
testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client);
});
it("Updates on event decryption", () => {
const testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client);
const roomNotifState = new RoomNotificationState(testRoom as any as Room);
const listener = jest.fn();
roomNotifState.addListener(NotificationStateEvents.Update, listener);
@ -40,4 +44,9 @@ describe("RoomNotificationState", () => {
client.emit(MatrixEventEvent.Decrypted, testEvent);
expect(listener).toHaveBeenCalled();
});
it("removes listeners", () => {
const roomNotifState = new RoomNotificationState(testRoom as any as Room);
expect(() => roomNotifState.destroy()).not.toThrow();
});
});

View File

@ -0,0 +1,60 @@
/*
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 { PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
import { stubClient } from "../../test-utils";
describe("RoomNotificationStateStore", () => {
const ROOM_ID = "!roomId:example.org";
let room;
let client;
beforeEach(() => {
stubClient();
client = MatrixClientPeg.get();
room = new Room(ROOM_ID, client, client.getUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
});
it("does not use legacy thread notification store", () => {
client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Stable);
expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).toBeNull();
});
it("use legacy thread notification store", () => {
client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported);
expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).not.toBeNull();
});
it("does not use legacy thread notification store", () => {
client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Stable);
RoomNotificationStateStore.instance.getRoomState(room);
expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).toBeNull();
});
it("use legacy thread notification store", () => {
client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported);
RoomNotificationStateStore.instance.getRoomState(room);
expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).not.toBeNull();
});
});

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { Room } from "matrix-js-sdk/src/matrix";
import { Room, RoomType } from "matrix-js-sdk/src/matrix";
import { VisibilityProvider } from "../../../../src/stores/room-list/filters/VisibilityProvider";
import LegacyCallHandler from "../../../../src/LegacyCallHandler";
@ -43,6 +43,7 @@ jest.mock("../../../../src/customisations/RoomList", () => ({
const createRoom = (isSpaceRoom = false): Room => {
return {
isSpaceRoom: () => isSpaceRoom,
getType: () => isSpaceRoom ? RoomType.Space : undefined,
} as unknown as Room;
};

View File

@ -31,6 +31,7 @@ import {
IEventRelation,
IUnsigned,
IPusher,
RoomType,
} from 'matrix-js-sdk/src/matrix';
import { normalize } from "matrix-js-sdk/src/utils";
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
@ -448,6 +449,7 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl
getAvatarUrl: () => 'mxc://avatar.url/room.png',
getMxcAvatarUrl: () => 'mxc://avatar.url/room.png',
isSpaceRoom: jest.fn().mockReturnValue(false),
getType: jest.fn().mockReturnValue(undefined),
isElementVideoRoom: jest.fn().mockReturnValue(false),
getUnreadNotificationCount: jest.fn(() => 0),
getEventReadUpTo: jest.fn(() => null),
@ -545,6 +547,7 @@ export const mkSpace = (
): MockedObject<Room> => {
const space = mocked(mkRoom(client, spaceId, rooms));
space.isSpaceRoom.mockReturnValue(true);
space.getType.mockReturnValue(RoomType.Space);
mocked(space.currentState).getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId =>
mkEvent({
event: true,

View File

@ -106,7 +106,7 @@ export const mkThread = ({
participantUserIds,
length = 2,
ts = 1,
}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent } => {
}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent, events: MatrixEvent[] } => {
const { rootEvent, events } = makeThreadEvents({
roomId: room.roomId,
authorId,
@ -120,5 +120,5 @@ export const mkThread = ({
// So that we do not have to mock the thread loading
thread.initialEventsFetched = true;
return { thread, rootEvent };
return { thread, rootEvent, events };
};

View File

@ -26,6 +26,8 @@ import {
VoiceBroadcastRecorderEvent,
} from "../../../src/voice-broadcast";
jest.mock("../../../src/audio/VoiceRecording");
describe("VoiceBroadcastRecorder", () => {
describe("createVoiceBroadcastRecorder", () => {
beforeEach(() => {
@ -44,6 +46,7 @@ describe("VoiceBroadcastRecorder", () => {
it("should return a VoiceBroadcastRecorder instance with targetChunkLength from config", () => {
const voiceBroadcastRecorder = createVoiceBroadcastRecorder();
expect(mocked(VoiceRecording).mock.instances[0].disableMaxLength).toHaveBeenCalled();
expect(voiceBroadcastRecorder).toBeInstanceOf(VoiceBroadcastRecorder);
expect(voiceBroadcastRecorder.targetChunkLength).toBe(1337);
});
@ -72,16 +75,12 @@ describe("VoiceBroadcastRecorder", () => {
};
beforeEach(() => {
voiceRecording = {
contentType,
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
destroy: jest.fn(),
recorderSeconds: 23,
} as unknown as VoiceRecording;
voiceRecording = new VoiceRecording();
// @ts-ignore
voiceRecording.recorderSeconds = 23;
// @ts-ignore
voiceRecording.contentType = contentType;
voiceBroadcastRecorder = new VoiceBroadcastRecorder(voiceRecording, chunkLength);
jest.spyOn(voiceBroadcastRecorder, "removeAllListeners");
onChunkRecorded = jest.fn();