Merge branch 'develop' of https://github.com/yaya-usman/matrix-react-sdk into favouriteMessages_Panel

This commit is contained in:
yaya-usman 2022-07-21 18:16:48 +03:00
commit a53f7f8302
70 changed files with 2195 additions and 996 deletions

View file

@ -22,7 +22,7 @@ jobs:
- id: prdetails
if: github.event.workflow_run.event == 'pull_request'
uses: matrix-org/pr-details-action@v1.1
uses: matrix-org/pr-details-action@v1.2
with:
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}

View file

@ -25,7 +25,7 @@ jobs:
Exercise caution. Use test accounts.
- id: prdetails
uses: matrix-org/pr-details-action@v1.1
uses: matrix-org/pr-details-action@v1.2
with:
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}

View file

@ -139,6 +139,9 @@ describe("Spotlight", () => {
const room2Name = "Lounge";
let room2Id: string;
const room3Name = "Public";
let room3Id: string;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
@ -163,6 +166,19 @@ describe("Spotlight", () => {
room2Id = _room2Id;
bot2.invite(room2Id, bot1.getUserId());
});
bot2.createRoom({
name: room3Name,
visibility: Visibility.Public, initial_state: [{
type: "m.room.history_visibility",
state_key: "",
content: {
history_visibility: "world_readable",
},
}],
}).then(({ room_id: _room3Id }) => {
room3Id = _room3Id;
bot2.invite(room3Id, bot1.getUserId());
});
}),
).then(() =>
cy.get('.mx_RoomSublist_skeletonUI').should('not.exist'),
@ -212,6 +228,7 @@ describe("Spotlight", () => {
cy.spotlightSearch().clear().type(room1Name);
cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", room1Name);
cy.spotlightResults().eq(0).should("contain", "View");
cy.spotlightResults().eq(0).click();
cy.url().should("contain", room1Id);
}).then(() => {
@ -225,6 +242,7 @@ describe("Spotlight", () => {
cy.spotlightSearch().clear().type(room2Name);
cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", room2Name);
cy.spotlightResults().eq(0).should("contain", "Join");
cy.spotlightResults().eq(0).click();
cy.url().should("contain", room2Id);
}).then(() => {
@ -233,6 +251,21 @@ describe("Spotlight", () => {
});
});
it("should find unknown public world readable rooms", () => {
cy.openSpotlightDialog().within(() => {
cy.spotlightFilter(Filter.PublicRooms);
cy.spotlightSearch().clear().type(room3Name);
cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", room3Name);
cy.spotlightResults().eq(0).should("contain", "View");
cy.spotlightResults().eq(0).click();
cy.url().should("contain", room3Id);
}).then(() => {
cy.get(".mx_RoomPreviewBar_actions .mx_AccessibleButton").click();
cy.roomHeaderName().should("contain", room3Name);
});
});
// TODO: We currently cant test finding rooms on other homeservers/other protocols
// We obviously dont have federation or bridges in cypress tests
/*

View file

@ -0,0 +1,249 @@
/*
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.
*/
/// <reference types="cypress" />
import { PollResponseEvent } from "matrix-events-sdk";
import { SynapseInstance } from "../../plugins/synapsedocker";
import { MatrixClient } from "../../global";
import Chainable = Cypress.Chainable;
const hideTimestampCSS = ".mx_MessageTimestamp { visibility: hidden !important; }";
describe("Polls", () => {
let synapse: SynapseInstance;
type CreatePollOptions = {
title: string;
options: string[];
};
const createPoll = ({ title, options }: CreatePollOptions) => {
if (options.length < 2) {
throw new Error('Poll must have at least two options');
}
cy.get('.mx_PollCreateDialog').within((pollCreateDialog) => {
cy.get('#poll-topic-input').type(title);
options.forEach((option, index) => {
const optionId = `#pollcreate_option_${index}`;
// click 'add option' button if needed
if (pollCreateDialog.find(optionId).length === 0) {
cy.get('.mx_PollCreateDialog_addOption').scrollIntoView().click();
}
cy.get(optionId).scrollIntoView().type(option);
});
});
cy.get('.mx_Dialog button[type="submit"]').click();
};
const getPollTile = (pollId: string): Chainable<JQuery> => {
return cy.get(`.mx_EventTile[data-scroll-tokens="${pollId}"]`);
};
const getPollOption = (pollId: string, optionText: string): Chainable<JQuery> => {
return getPollTile(pollId).contains('.mx_MPollBody_option .mx_StyledRadioButton', optionText);
};
const expectPollOptionVoteCount = (pollId: string, optionText: string, votes: number): void => {
getPollOption(pollId, optionText).within(() => {
cy.get('.mx_MPollBody_optionVoteCount').should('contain', `${votes} vote`);
});
};
const botVoteForOption = (bot: MatrixClient, roomId: string, pollId: string, optionText: string): void => {
getPollOption(pollId, optionText).within(ref => {
cy.get('input[type="radio"]').invoke('attr', 'value').then(optionId => {
const pollVote = PollResponseEvent.from([optionId], pollId).serialize();
bot.sendEvent(
roomId,
pollVote.type,
pollVote.content,
);
});
});
};
beforeEach(() => {
cy.enableLabsFeature("feature_thread");
cy.window().then(win => {
win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests
});
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Tom");
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
it("Open polls can be created and voted in", () => {
let bot: MatrixClient;
cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => {
bot = _bot;
});
let roomId: string;
cy.createRoom({}).then(_roomId => {
roomId = _roomId;
cy.inviteUser(roomId, bot.getUserId());
cy.visit('/#/room/' + roomId);
});
cy.openMessageComposerOptions().within(() => {
cy.get('[aria-label="Poll"]').click();
});
cy.get('.mx_CompoundDialog').percySnapshotElement('Polls Composer');
const pollParams = {
title: 'Does the polls feature work?',
options: ['Yes', 'No', 'Maybe'],
};
createPoll(pollParams);
// Wait for message to send, get its ID and save as @pollId
cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title)
.invoke("attr", "data-scroll-tokens").as("pollId");
cy.get<string>("@pollId").then(pollId => {
getPollTile(pollId).percySnapshotElement('Polls Timeline tile - no votes', { percyCSS: hideTimestampCSS });
// Bot votes 'Maybe' in the poll
botVoteForOption(bot, roomId, pollId, pollParams.options[2]);
// no votes shown until I vote, check bots vote has arrived
cy.get('.mx_MPollBody_totalVotes').should('contain', '1 vote cast');
// vote 'Maybe'
getPollOption(pollId, pollParams.options[2]).click('topLeft');
// both me and bot have voted Maybe
expectPollOptionVoteCount(pollId, pollParams.options[2], 2);
// change my vote to 'Yes'
getPollOption(pollId, pollParams.options[0]).click('topLeft');
// 1 vote for yes
expectPollOptionVoteCount(pollId, pollParams.options[0], 1);
// 1 vote for maybe
expectPollOptionVoteCount(pollId, pollParams.options[2], 1);
// Bot updates vote to 'No'
botVoteForOption(bot, roomId, pollId, pollParams.options[1]);
// 1 vote for yes
expectPollOptionVoteCount(pollId, pollParams.options[0], 1);
// 1 vote for no
expectPollOptionVoteCount(pollId, pollParams.options[0], 1);
// 0 for maybe
expectPollOptionVoteCount(pollId, pollParams.options[2], 0);
});
});
it("displays polls correctly in thread panel", () => {
let botBob: MatrixClient;
let botCharlie: MatrixClient;
cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => {
botBob = _bot;
});
cy.getBot(synapse, { displayName: "BotCharlie" }).then(_bot => {
botCharlie = _bot;
});
let roomId: string;
cy.createRoom({}).then(_roomId => {
roomId = _roomId;
cy.inviteUser(roomId, botBob.getUserId());
cy.inviteUser(roomId, botCharlie.getUserId());
cy.visit('/#/room/' + roomId);
});
cy.openMessageComposerOptions().within(() => {
cy.get('[aria-label="Poll"]').click();
});
const pollParams = {
title: 'Does the polls feature work?',
options: ['Yes', 'No', 'Maybe'],
};
createPoll(pollParams);
// Wait for message to send, get its ID and save as @pollId
cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title)
.invoke("attr", "data-scroll-tokens").as("pollId");
cy.get<string>("@pollId").then(pollId => {
// Bob starts thread on the poll
botBob.sendMessage(roomId, pollId, {
body: "Hello there",
msgtype: "m.text",
});
// open the thread summary
cy.get(".mx_RoomView_body .mx_ThreadSummary").click();
// Bob votes 'Maybe' in the poll
botVoteForOption(botBob, roomId, pollId, pollParams.options[2]);
// Charlie votes 'No'
botVoteForOption(botCharlie, roomId, pollId, pollParams.options[1]);
// no votes shown until I vote, check votes have arrived in main tl
cy.get('.mx_RoomView_body .mx_MPollBody_totalVotes').should('contain', '2 votes cast');
// and thread view
cy.get('.mx_ThreadView .mx_MPollBody_totalVotes').should('contain', '2 votes cast');
cy.get('.mx_RoomView_body').within(() => {
// vote 'Maybe' in the main timeline poll
getPollOption(pollId, pollParams.options[2]).click('topLeft');
// both me and bob have voted Maybe
expectPollOptionVoteCount(pollId, pollParams.options[2], 2);
});
cy.get('.mx_ThreadView').within(() => {
// votes updated in thread view too
expectPollOptionVoteCount(pollId, pollParams.options[2], 2);
// change my vote to 'Yes'
getPollOption(pollId, pollParams.options[0]).click('topLeft');
});
// Bob updates vote to 'No'
botVoteForOption(botBob, roomId, pollId, pollParams.options[1]);
// me: yes, bob: no, charlie: no
const expectVoteCounts = () => {
// I voted yes
expectPollOptionVoteCount(pollId, pollParams.options[0], 1);
// Bob and Charlie voted no
expectPollOptionVoteCount(pollId, pollParams.options[1], 2);
// 0 for maybe
expectPollOptionVoteCount(pollId, pollParams.options[2], 0);
};
// check counts are correct in main timeline tile
cy.get('.mx_RoomView_body').within(() => {
expectVoteCounts();
});
// and in thread view tile
cy.get('.mx_ThreadView').within(() => {
expectVoteCounts();
});
});
});
});

View file

@ -0,0 +1,174 @@
/*
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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import { MatrixClient } from "../../global";
import Chainable = Cypress.Chainable;
interface Charly {
client: MatrixClient;
displayName: string;
}
describe("Lazy Loading", () => {
let synapse: SynapseInstance;
let bob: MatrixClient;
const charlies: Charly[] = [];
beforeEach(() => {
cy.window().then(win => {
win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests
});
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Alice");
cy.getBot(synapse, {
displayName: "Bob",
startClient: false,
autoAcceptInvites: false,
}).then(_bob => {
bob = _bob;
});
for (let i = 1; i <= 10; i++) {
const displayName = `Charly #${i}`;
cy.getBot(synapse, {
displayName,
startClient: false,
autoAcceptInvites: false,
}).then(client => {
charlies[i - 1] = { displayName, client };
});
}
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
const name = "Lazy Loading Test";
const alias = "#lltest:localhost";
const charlyMsg1 = "hi bob!";
const charlyMsg2 = "how's it going??";
function setupRoomWithBobAliceAndCharlies(charlies: Charly[]) {
cy.window({ log: false }).then(win => {
return cy.wrap(bob.createRoom({
name,
room_alias_name: "lltest",
visibility: win.matrixcs.Visibility.Public,
}).then(r => r.room_id), { log: false }).as("roomId");
});
cy.get<string>("@roomId").then(async roomId => {
for (const charly of charlies) {
await charly.client.joinRoom(alias);
}
for (const charly of charlies) {
cy.botSendMessage(charly.client, roomId, charlyMsg1);
}
for (const charly of charlies) {
cy.botSendMessage(charly.client, roomId, charlyMsg2);
}
for (let i = 20; i >= 1; --i) {
cy.botSendMessage(bob, roomId, `I will only say this ${i} time(s)!`);
}
});
cy.joinRoom(alias);
cy.viewRoomByName(name);
}
function checkPaginatedDisplayNames(charlies: Charly[]) {
cy.scrollToTop();
for (const charly of charlies) {
cy.findEventTile(charly.displayName, charlyMsg1).should("exist");
cy.findEventTile(charly.displayName, charlyMsg2).should("exist");
}
}
function openMemberlist(): void {
cy.get('.mx_HeaderButtons [aria-label="Room Info"]').click();
cy.get(".mx_RoomSummaryCard").within(() => {
cy.get(".mx_RoomSummaryCard_icon_people").click();
});
}
function getMembersInMemberlist(): Chainable<JQuery> {
return cy.get(".mx_MemberList .mx_EntityTile_name");
}
function checkMemberList(charlies: Charly[]) {
getMembersInMemberlist().contains("Alice").should("exist");
getMembersInMemberlist().contains("Bob").should("exist");
charlies.forEach(charly => {
getMembersInMemberlist().contains(charly.displayName).should("exist");
});
}
function checkMemberListLacksCharlies(charlies: Charly[]) {
charlies.forEach(charly => {
getMembersInMemberlist().contains(charly.displayName).should("not.exist");
});
}
function joinCharliesWhileAliceIsOffline(charlies: Charly[]) {
cy.goOffline();
cy.get<string>("@roomId").then(async roomId => {
for (const charly of charlies) {
await charly.client.joinRoom(alias);
}
for (let i = 20; i >= 1; --i) {
cy.botSendMessage(charlies[0].client, roomId, "where is charly?");
}
});
cy.goOnline();
cy.wait(1000); // Ideally we'd await a /sync here but intercepts step on each other from going offline/online
}
it("should handle lazy loading properly even when offline", () => {
const charly1to5 = charlies.slice(0, 5);
const charly6to10 = charlies.slice(5);
// Set up room with alice, bob & charlies 1-5
setupRoomWithBobAliceAndCharlies(charly1to5);
// Alice should see 2 messages from every charly with the correct display name
checkPaginatedDisplayNames(charly1to5);
openMemberlist();
checkMemberList(charly1to5);
joinCharliesWhileAliceIsOffline(charly6to10);
checkMemberList(charly6to10);
cy.get<string>("@roomId").then(async roomId => {
for (const charly of charlies) {
await charly.client.leave(roomId);
}
});
checkMemberListLacksCharlies(charlies);
});
});

View file

@ -18,7 +18,7 @@ limitations under the License.
import request from "browser-request";
import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { SynapseInstance } from "../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
@ -31,10 +31,15 @@ interface CreateBotOpts {
* The display name to give to that bot user
*/
displayName?: string;
/**
* Whether or not to start the syncing client.
*/
startClient?: boolean;
}
const defaultCreateBotOptions = {
autoAcceptInvites: true,
startClient: true,
} as CreateBotOpts;
declare global {
@ -59,6 +64,13 @@ declare global {
* @param roomName Name of the room to join
*/
botJoinRoomByName(cli: MatrixClient, roomName: string): Chainable<Room>;
/**
* Send a message as a bot into a room
* @param cli The bot's MatrixClient
* @param roomId ID of the room to join
* @param message the message body to send
*/
botSendMessage(cli: MatrixClient, roomId: string, message: string): Chainable<ISendEventResponse>;
}
}
}
@ -88,6 +100,10 @@ Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts):
});
}
if (!opts.startClient) {
return cy.wrap(cli);
}
return cy.wrap(
cli.initCrypto()
.then(() => cli.setGlobalErrorOnUnknownDevices(false))
@ -114,3 +130,14 @@ Cypress.Commands.add("botJoinRoomByName", (cli: MatrixClient, roomName: string):
return cy.wrap(Promise.reject());
});
Cypress.Commands.add("botSendMessage", (
cli: MatrixClient,
roomId: string,
message: string,
): Chainable<ISendEventResponse> => {
return cy.wrap(cli.sendMessage(roomId, {
msgtype: "m.text",
body: message,
}), { log: false });
});

View file

@ -124,6 +124,11 @@ declare global {
* Boostraps cross-signing.
*/
bootstrapCrossSigning(): Chainable<void>;
/**
* Joins the given room by alias or ID
* @param roomIdOrAlias the id or alias of the room to join
*/
joinRoom(roomIdOrAlias: string): Chainable<Room>;
}
}
}
@ -217,3 +222,7 @@ Cypress.Commands.add("bootstrapCrossSigning", () => {
});
});
});
Cypress.Commands.add("joinRoom", (roomIdOrAlias: string): Chainable<Room> => {
return cy.getClient().then(cli => cli.joinRoom(roomIdOrAlias));
});

View file

@ -0,0 +1,48 @@
/*
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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
// Get the composer element
// selects main timeline composer by default
// set `isRightPanel` true to select right panel composer
getComposer(isRightPanel?: boolean): Chainable<JQuery>;
// Open the message composer kebab menu
openMessageComposerOptions(isRightPanel?: boolean): Chainable<JQuery>;
}
}
}
Cypress.Commands.add("getComposer", (isRightPanel?: boolean): Chainable<JQuery> => {
const panelClass = isRightPanel ? '.mx_RightPanel' : '.mx_RoomView_body';
return cy.get(`${panelClass} .mx_MessageComposer`);
});
Cypress.Commands.add("openMessageComposerOptions", (isRightPanel?: boolean): Chainable<JQuery> => {
cy.getComposer(isRightPanel).within(() => {
cy.get('[aria-label="More options"]').click();
});
return cy.get('.mx_MessageComposer_Menu');
});
// Needed to make this file a module
export { };

View file

@ -33,3 +33,6 @@ import "./percy";
import "./webserver";
import "./views";
import "./iframes";
import "./timeline";
import "./network";
import "./composer";

View file

@ -0,0 +1,62 @@
/*
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.
*/
/// <reference types="cypress" />
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
// Intercept all /_matrix/ networking requests for the logged in user and fail them
goOffline(): void;
// Remove intercept on all /_matrix/ networking requests
goOnline(): void;
}
}
}
// We manage intercepting Matrix APIs here, as fully disabling networking will disconnect
// the browser under test from the Cypress runner, so can cause issues.
Cypress.Commands.add("goOffline", (): void => {
cy.log("Going offline");
cy.window({ log: false }).then(win => {
cy.intercept("**/_matrix/**", {
headers: {
"Authorization": "Bearer " + win.mxMatrixClientPeg.matrixClient.getAccessToken(),
},
}, req => {
req.destroy();
});
});
});
Cypress.Commands.add("goOnline", (): void => {
cy.log("Going online");
cy.window({ log: false }).then(win => {
cy.intercept("**/_matrix/**", {
headers: {
"Authorization": "Bearer " + win.mxMatrixClientPeg.matrixClient.getAccessToken(),
},
}, req => {
req.continue();
});
win.dispatchEvent(new Event("online"));
});
});
// Needed to make this file a module
export { };

View file

@ -0,0 +1,68 @@
/*
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.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
// Scroll to the top of the timeline
scrollToTop(): void;
// Find the event tile matching the given sender & body
findEventTile(sender: string, body: string): Chainable<JQuery>;
}
}
}
export interface Message {
sender: string;
body: string;
encrypted: boolean;
continuation: boolean;
}
Cypress.Commands.add("scrollToTop", (): void => {
cy.get(".mx_RoomView_timeline .mx_ScrollPanel").scrollTo("top", { duration: 100 }).then(ref => {
if (ref.scrollTop() > 0) {
return cy.scrollToTop();
}
});
});
Cypress.Commands.add("findEventTile", (sender: string, body: string): Chainable<JQuery> => {
// We can't just use a bunch of `.contains` here due to continuations meaning that the events don't
// have their own rendered sender displayname so we have to walk the list to keep track of the sender.
return cy.get(".mx_RoomView_MessageList .mx_EventTile").then(refs => {
let latestSender: string;
for (let i = 0; i < refs.length; i++) {
const ref = refs.eq(i);
const displayName = ref.find(".mx_DisambiguatedProfile_displayName");
if (displayName) {
latestSender = displayName.text();
}
if (latestSender === sender && ref.find(".mx_EventTile_body").text() === body) {
return ref;
}
}
});
});
// Needed to make this file a module
export { };

View file

@ -172,7 +172,7 @@
"blob-polyfill": "^6.0.20211015",
"chokidar": "^3.5.1",
"cypress": "^10.3.0",
"cypress-real-events": "^1.7.0",
"cypress-real-events": "^1.7.1",
"enzyme": "^3.11.0",
"enzyme-to-json": "^3.6.2",
"eslint": "8.9.0",

View file

@ -22,6 +22,8 @@ limitations under the License.
padding: $spacing-12 0;
border-bottom: 1px solid $system;
cursor: pointer;
}
.mx_BeaconListItem_avatarIcon {
@ -61,3 +63,8 @@ limitations under the License.
color: $tertiary-content;
font-size: $font-10px;
}
.mx_BeaconListItem_interactions {
display: flex;
flex-direction: row;
}

View file

@ -138,7 +138,7 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/trashcan.svg');
}
&.mx_RoomStatusBar_unsentResendAllBtn {
&.mx_RoomStatusBar_unsentRetry {
padding-left: 34px; // 28px from above, but +6px to account for the wider icon
&::before {

View file

@ -100,7 +100,7 @@ limitations under the License.
display: flex;
user-select: none;
&:hover {
&:not(.mx_RoomHeader_name--textonly):hover {
background-color: $quinary-content;
}
@ -139,7 +139,7 @@ limitations under the License.
opacity: 0.6;
}
.mx_RoomHeader_name,
.mx_RoomHeader_name:not(.mx_RoomHeader_name--textonly),
.mx_RoomHeader_avatar {
cursor: pointer;
}

View file

@ -22,6 +22,7 @@ import { split } from "lodash";
import DMRoomMap from './utils/DMRoomMap';
import { mediaFromMxc } from "./customisations/Media";
import { isLocalRoom } from "./utils/localRoom/isLocalRoom";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember(
@ -142,7 +143,12 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
if (room.isSpaceRoom()) return null;
// If the room is not a DM don't fallback to a member avatar
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) return null;
if (
!DMRoomMap.shared().getUserIdForRoomId(room.roomId)
&& !(isLocalRoom(room))
) {
return null;
}
// If there are only two members in the DM use the avatar of the other member
const otherMember = room.getAvatarFallbackMember();

View file

@ -17,6 +17,7 @@ limitations under the License.
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { M_BEACON } from "matrix-js-sdk/src/@types/beacon";
import { MatrixClientPeg } from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent';
@ -44,6 +45,8 @@ export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
case EventType.RoomAliases:
case EventType.RoomCanonicalAlias:
case EventType.RoomServerAcl:
case M_BEACON.name:
case M_BEACON.altName:
return false;
}

View file

@ -29,8 +29,9 @@ import QueryMatcher from './QueryMatcher';
import { PillCompletion } from './Components';
import { ICompletion, ISelectionRange } from './Autocompleter';
import SettingsStore from "../settings/SettingsStore";
import { EMOJI, IEmoji } from '../emoji';
import { EMOJI, IEmoji, getEmojiFromUnicode } from '../emoji';
import { TimelineRenderingType } from '../contexts/RoomContext';
import * as recent from '../emojipicker/recent';
const LIMIT = 20;
@ -73,6 +74,7 @@ function colonsTrimmed(str: string): string {
export default class EmojiProvider extends AutocompleteProvider {
matcher: QueryMatcher<ISortedEmoji>;
nameMatcher: QueryMatcher<ISortedEmoji>;
private readonly recentlyUsed: IEmoji[];
constructor(room: Room, renderingType?: TimelineRenderingType) {
super({ commandRegex: EMOJI_REGEX, renderingType });
@ -87,6 +89,8 @@ export default class EmojiProvider extends AutocompleteProvider {
// For removing punctuation
shouldMatchWordsOnly: true,
});
this.recentlyUsed = Array.from(new Set(recent.get().map(getEmojiFromUnicode).filter(Boolean)));
}
async getCompletions(
@ -109,7 +113,7 @@ export default class EmojiProvider extends AutocompleteProvider {
// Do second match with shouldMatchWordsOnly in order to match against 'name'
completions = completions.concat(this.nameMatcher.match(matchedString));
const sorters = [];
let sorters = [];
// make sure that emoticons come first
sorters.push(c => score(matchedString, c.emoji.emoticon || ""));
@ -130,6 +134,15 @@ export default class EmojiProvider extends AutocompleteProvider {
sorters.push(c => c._orderBy);
completions = sortBy(uniq(completions), sorters);
completions = completions.slice(0, LIMIT);
// Do a second sort to place emoji matching with frequently used one on top
sorters = [];
this.recentlyUsed.forEach(emoji => {
sorters.push(c => score(emoji.shortcodes[0], c.emoji.shortcodes[0]));
});
completions = sortBy(uniq(completions), sorters);
completions = completions.map(c => ({
completion: c.emoji.unicode,
component: (
@ -138,7 +151,7 @@ export default class EmojiProvider extends AutocompleteProvider {
</PillCompletion>
),
range,
})).slice(0, LIMIT);
}));
}
return completions;
}

View file

@ -132,6 +132,7 @@ import VideoChannelStore from "../../stores/VideoChannelStore";
import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators";
import { UseCaseSelection } from '../views/elements/UseCaseSelection';
import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig';
import { isLocalRoom } from '../../utils/localRoom/isLocalRoom';
// legacy export
export { default as Views } from "../../Views";
@ -890,7 +891,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
// If we are redirecting to a Room Alias and it is for the room we already showing then replace history item
const replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId;
let replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId;
if (isLocalRoom(this.state.currentRoomId)) {
// Replace local room history items
replaceLast = true;
}
if (roomInfo.room_id === this.state.currentRoomId) {
// if we are re-viewing the same room then copy any state we already know

View file

@ -24,11 +24,11 @@ import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
import { messageForResourceLimitError } from '../../utils/ErrorUtils';
import { Action } from "../../dispatcher/actions";
import NotificationBadge from "../views/rooms/NotificationBadge";
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
import AccessibleButton from "../views/elements/AccessibleButton";
import InlineSpinner from "../views/elements/InlineSpinner";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages';
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -240,7 +240,7 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
<AccessibleButton onClick={this.onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
{ _t("Delete all") }
</AccessibleButton>
<AccessibleButton onClick={this.onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
<AccessibleButton onClick={this.onResendAllClick} className="mx_RoomStatusBar_unsentRetry">
{ _t("Retry all") }
</AccessibleButton>
</>;
@ -252,28 +252,12 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
</>;
}
return <>
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
<div role="alert">
<div className="mx_RoomStatusBar_unsentBadge">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
/>
</div>
<div>
<div className="mx_RoomStatusBar_unsentTitle">
{ title }
</div>
<div className="mx_RoomStatusBar_unsentDescription">
{ _t("You can select all or individual messages to retry or delete") }
</div>
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">
{ buttonRow }
</div>
</div>
</div>
</>;
return <RoomStatusBarUnsentMessages
title={title}
description={_t("You can select all or individual messages to retry or delete")}
notificationState={StaticNotificationState.RED_EXCLAMATION}
buttons={buttonRow}
/>;
}
public render(): JSX.Element {

View file

@ -0,0 +1,55 @@
/*
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, { ReactElement } from "react";
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
import NotificationBadge from "../views/rooms/NotificationBadge";
interface RoomStatusBarUnsentMessagesProps {
title: string;
description?: string;
notificationState: StaticNotificationState;
buttons: ReactElement;
}
export const RoomStatusBarUnsentMessages = (props: RoomStatusBarUnsentMessagesProps): ReactElement => {
return (
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
<div role="alert">
<div className="mx_RoomStatusBar_unsentBadge">
<NotificationBadge
notification={props.notificationState}
/>
</div>
<div>
<div className="mx_RoomStatusBar_unsentTitle">
{ props.title }
</div>
{
props.description &&
<div className="mx_RoomStatusBar_unsentDescription">
{ props.description }
</div>
}
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">
{ props.buttons }
</div>
</div>
</div>
);
};

View file

@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext } from 'react';
import React, { HTMLProps, useContext } from 'react';
import { Beacon, BeaconEvent } from 'matrix-js-sdk/src/matrix';
import { LocationAssetType } from 'matrix-js-sdk/src/@types/location';
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import { useEventEmitterState } from '../../../hooks/useEventEmitter';
import { humanizeTime } from '../../../utils/humanize';
import { preventDefaultWrapper } from '../../../utils/NativeEventUtils';
import { _t } from '../../../languageHandler';
import MemberAvatar from '../avatars/MemberAvatar';
import BeaconStatus from './BeaconStatus';
@ -32,7 +33,7 @@ interface Props {
beacon: Beacon;
}
const BeaconListItem: React.FC<Props> = ({ beacon }) => {
const BeaconListItem: React.FC<Props & HTMLProps<HTMLLIElement>> = ({ beacon, ...rest }) => {
const latestLocationState = useEventEmitterState(
beacon,
BeaconEvent.LocationUpdate,
@ -52,7 +53,7 @@ const BeaconListItem: React.FC<Props> = ({ beacon }) => {
const humanizedUpdateTime = humanizeTime(latestLocationState.timestamp);
return <li className='mx_BeaconListItem'>
return <li className='mx_BeaconListItem' {...rest}>
{ isSelfLocation ?
<MemberAvatar
className='mx_BeaconListItem_avatar'
@ -69,7 +70,11 @@ const BeaconListItem: React.FC<Props> = ({ beacon }) => {
label={beaconMember?.name || beacon.beaconInfo.description || beacon.beaconInfoOwner}
displayStatus={BeaconDisplayStatus.Active}
>
<ShareLatestLocation latestLocationState={latestLocationState} />
{ /* eat events from interactive share buttons
so parent click handlers are not triggered */ }
<div className='mx_BeaconListItem_interactions' onClick={preventDefaultWrapper(() => {})}>
<ShareLatestLocation latestLocationState={latestLocationState} />
</div>
</BeaconStatus>
<span className='mx_BeaconListItem_lastUpdated'>{ _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) }</span>
</div>

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import {
Beacon,
@ -45,7 +45,16 @@ interface IProps extends IDialogProps {
roomId: Room['roomId'];
matrixClient: MatrixClient;
// open the map centered on this beacon's location
focusBeacon?: Beacon;
initialFocusedBeacon?: Beacon;
}
// track the 'focused time' as ts
// to make it possible to refocus the same beacon
// as the beacon location may change
// or the map may move around
interface FocusedBeaconState {
ts: number;
beacon?: Beacon;
}
const getBoundsCenter = (bounds: Bounds): string | undefined => {
@ -59,31 +68,52 @@ const getBoundsCenter = (bounds: Bounds): string | undefined => {
});
};
const useInitialMapPosition = (liveBeacons: Beacon[], focusBeacon?: Beacon): {
const useInitialMapPosition = (liveBeacons: Beacon[], { beacon, ts }: FocusedBeaconState): {
bounds?: Bounds; centerGeoUri: string;
} => {
const bounds = useRef<Bounds | undefined>(getBeaconBounds(liveBeacons));
const centerGeoUri = useRef<string>(
focusBeacon?.latestLocationState?.uri ||
getBoundsCenter(bounds.current),
const [bounds, setBounds] = useState<Bounds | undefined>(getBeaconBounds(liveBeacons));
const [centerGeoUri, setCenterGeoUri] = useState<string>(
beacon?.latestLocationState?.uri ||
getBoundsCenter(bounds),
);
return { bounds: bounds.current, centerGeoUri: centerGeoUri.current };
useEffect(() => {
if (
// this check ignores the first initial focused beacon state
// as centering logic on map zooms to show everything
// instead of focusing down
ts !== 0 &&
// only set focus to a known location
beacon?.latestLocationState?.uri
) {
// append custom `mxTs` parameter to geoUri
// so map is triggered to refocus on this uri
// event if it was previously the center geouri
// but the map have moved/zoomed
setCenterGeoUri(`${beacon?.latestLocationState?.uri};mxTs=${Date.now()}`);
setBounds(getBeaconBounds([beacon]));
}
}, [beacon, ts]);
return { bounds, centerGeoUri };
};
/**
* Dialog to view live beacons maximised
*/
const BeaconViewDialog: React.FC<IProps> = ({
focusBeacon,
initialFocusedBeacon,
roomId,
matrixClient,
onFinished,
}) => {
const liveBeacons = useLiveBeacons(roomId, matrixClient);
const [focusedBeaconState, setFocusedBeaconState] =
useState<FocusedBeaconState>({ beacon: initialFocusedBeacon, ts: 0 });
const [isSidebarOpen, setSidebarOpen] = useState(false);
const { bounds, centerGeoUri } = useInitialMapPosition(liveBeacons, focusBeacon);
const { bounds, centerGeoUri } = useInitialMapPosition(liveBeacons, focusedBeaconState);
const [mapDisplayError, setMapDisplayError] = useState<Error>();
@ -94,6 +124,10 @@ const BeaconViewDialog: React.FC<IProps> = ({
}
}, [mapDisplayError]);
const onBeaconListItemClick = (beacon: Beacon) => {
setFocusedBeaconState({ beacon, ts: Date.now() });
};
return (
<BaseDialog
className='mx_BeaconViewDialog'
@ -144,7 +178,7 @@ const BeaconViewDialog: React.FC<IProps> = ({
</MapFallback>
}
{ isSidebarOpen ?
<DialogSidebar beacons={liveBeacons} requestClose={() => setSidebarOpen(false)} /> :
<DialogSidebar beacons={liveBeacons} onBeaconClick={onBeaconListItemClick} requestClose={() => setSidebarOpen(false)} /> :
<AccessibleButton
kind='primary'
onClick={() => setSidebarOpen(true)}

View file

@ -26,9 +26,14 @@ import BeaconListItem from './BeaconListItem';
interface Props {
beacons: Beacon[];
requestClose: () => void;
onBeaconClick: (beacon: Beacon) => void;
}
const DialogSidebar: React.FC<Props> = ({ beacons, requestClose }) => {
const DialogSidebar: React.FC<Props> = ({
beacons,
onBeaconClick,
requestClose,
}) => {
return <div className='mx_DialogSidebar'>
<div className='mx_DialogSidebar_header'>
<Heading size='h4'>{ _t('View List') }</Heading>
@ -36,13 +41,17 @@ const DialogSidebar: React.FC<Props> = ({ beacons, requestClose }) => {
className='mx_DialogSidebar_closeButton'
onClick={requestClose}
title={_t('Close sidebar')}
data-test-id='dialog-sidebar-close'
data-testid='dialog-sidebar-close'
>
<CloseIcon className='mx_DialogSidebar_closeButtonIcon' />
</AccessibleButton>
</div>
<ol className='mx_DialogSidebar_list'>
{ beacons.map((beacon) => <BeaconListItem key={beacon.identifier} beacon={beacon} />) }
{ beacons.map((beacon) => <BeaconListItem
key={beacon.identifier}
beacon={beacon}
onClick={() => onBeaconClick(beacon)}
/>) }
</ol>
</div>;
};

View file

@ -19,6 +19,7 @@ import React, { HTMLProps } from 'react';
import { _t } from '../../../languageHandler';
import { useOwnLiveBeacons } from '../../../utils/beacon';
import { preventDefaultWrapper } from '../../../utils/NativeEventUtils';
import BeaconStatus from './BeaconStatus';
import { BeaconDisplayStatus } from './displayStatus';
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
@ -45,14 +46,6 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
onResetLocationPublishError,
} = useOwnLiveBeacons([beacon?.identifier]);
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
const preventDefaultWrapper = (callback: () => void) => (e?: ButtonEvent) => {
e?.stopPropagation();
e?.preventDefault();
callback();
};
// combine display status with errors that only occur for user's own beacons
const ownDisplayStatus = hasLocationPublishError || hasStopSharingError ?
BeaconDisplayStatus.Error :
@ -68,7 +61,9 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
{ ownDisplayStatus === BeaconDisplayStatus.Active && <AccessibleButton
data-test-id='beacon-status-stop-beacon'
kind='link'
onClick={preventDefaultWrapper(onStopSharing)}
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper<ButtonEvent>(onStopSharing)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
disabled={stoppingInProgress}
>
@ -78,6 +73,8 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
{ hasLocationPublishError && <AccessibleButton
data-test-id='beacon-status-reset-wire-error'
kind='link'
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper(onResetLocationPublishError)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
>
@ -87,6 +84,8 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
{ hasStopSharingError && <AccessibleButton
data-test-id='beacon-status-stop-beacon-retry'
kind='link'
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper(onStopSharing)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
>

View file

@ -91,6 +91,7 @@ import { PublicRoomResultDetails } from "./PublicRoomResultDetails";
import { RoomResultContextMenus } from "./RoomResultContextMenus";
import { RoomContextDetails } from "../../rooms/RoomContextDetails";
import { TooltipOption } from "./TooltipOption";
import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom";
const MAX_RECENT_SEARCHES = 10;
const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons
@ -243,6 +244,9 @@ export const useWebSearchMetrics = (numResults: number, queryLength: number, via
const findVisibleRooms = (cli: MatrixClient) => {
return cli.getVisibleRooms().filter(room => {
// Do not show local rooms
if (isLocalRoom(room)) return false;
// TODO we may want to put invites in their own list
return room.getMyMembership() === "join" || room.getMyMembership() == "invite";
});
@ -395,7 +399,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
possibleResults.forEach(entry => {
if (isRoomResult(entry)) {
if (!entry.room.normalizedName.includes(normalizedQuery) &&
if (!entry.room.normalizedName?.includes(normalizedQuery) &&
!entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) &&
!entry.query?.some(q => q.includes(lcQuery))
) return; // bail, does not match query
@ -603,6 +607,16 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
}
if (isPublicRoomResult(result)) {
const clientRoom = cli.getRoom(result.publicRoom.room_id);
// Element Web currently does not allow guests to join rooms, so we
// instead show them view buttons for all rooms. If the room is not
// world readable, a modal will appear asking you to register first. If
// it is readable, the preview appears as normal.
const showViewButton = (
clientRoom?.getMyMembership() === "join" ||
result.publicRoom.world_readable ||
cli.isGuest()
);
const listener = (ev) => {
const { publicRoom } = result;
viewRoom({
@ -618,11 +632,11 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
onClick={listener}
endAdornment={
<AccessibleButton
kind={clientRoom ? "primary" : "primary_outline"}
kind={showViewButton ? "primary_outline" : "primary"}
onClick={listener}
tabIndex={-1}
>
{ _t(clientRoom ? "View" : "Join") }
{ showViewButton ? _t("View") : _t("Join") }
</AccessibleButton>}
aria-labelledby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_name`}
aria-describedby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_alias`}

View file

@ -80,6 +80,13 @@ const useMapWithStyle = ({ id, centerGeoUri, onError, interactive, bounds }) =>
interface MapProps {
id: string;
interactive?: boolean;
/**
* set map center to geoUri coords
* Center will only be set to valid geoUri
* this prop is only simply diffed by useEffect, so to trigger *recentering* of the same geoUri
* append the uri with a var not used by the geoUri spec
* eg a timestamp: `geo:54,42;mxTs=123`
*/
centerGeoUri?: string;
bounds?: Bounds;
className?: string;

View file

@ -24,6 +24,7 @@ import EventTileBubble from "./EventTileBubble";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import DMRoomMap from "../../../utils/DMRoomMap";
import { objectHasDiff } from "../../../utils/objects";
import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom';
interface IProps {
mxEvent: MatrixEvent;
@ -46,12 +47,15 @@ const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({ mxEvent, timestamp
if (content.algorithm === ALGORITHM && isRoomEncrypted) {
let subtitle: string;
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
const room = cli?.getRoom(roomId);
if (prevContent.algorithm === ALGORITHM) {
subtitle = _t("Some encryption parameters have been changed.");
} else if (dmPartner) {
const displayName = cli?.getRoom(roomId)?.getMember(dmPartner)?.rawDisplayName || dmPartner;
const displayName = room.getMember(dmPartner)?.rawDisplayName || dmPartner;
subtitle = _t("Messages here are end-to-end encrypted. " +
"Verify %(displayName)s in their profile - tap on their avatar.", { displayName });
} else if (isLocalRoom(room)) {
subtitle = _t("Messages in this chat will be end-to-end encrypted.");
} else {
subtitle = _t("Messages in this room are end-to-end encrypted. " +
"When people join, you can verify them in their profile, just tap on their avatar.");

View file

@ -162,7 +162,7 @@ const MBeaconBody: React.FC<IBodyProps> = React.forwardRef(({ mxEvent, getRelati
{
roomId: mxEvent.getRoomId(),
matrixClient,
focusBeacon: beacon,
initialFocusedBeacon: beacon,
isMapDisplayError,
},
"mx_BeaconViewDialog_wrapper",

View file

@ -80,6 +80,7 @@ import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../event
import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
import { ReadReceiptGroup } from './ReadReceiptGroup';
import { useTooltip } from "../../../utils/useTooltip";
import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom';
export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations;
@ -766,6 +767,9 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
private renderE2EPadlock() {
const ev = this.props.mxEvent;
// no icon for local rooms
if (isLocalRoom(ev.getRoomId())) return;
// event could not be decrypted
if (ev.getContent().msgtype === 'm.bad.encrypted') {
return <E2ePadlockUndecryptable />;

View file

@ -38,6 +38,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { privateShouldBeEncrypted } from "../../../utils/rooms";
import { LocalRoom } from "../../../models/LocalRoom";
function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
@ -49,11 +50,19 @@ const NewRoomIntro = () => {
const cli = useContext(MatrixClientContext);
const { room, roomId } = useContext(RoomContext);
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
const isLocalRoom = room instanceof LocalRoom;
const dmPartner = isLocalRoom
? room.targets[0]?.userId
: DMRoomMap.shared().getUserIdForRoomId(roomId);
let body;
if (dmPartner) {
let introMessage = _t("This is the beginning of your direct message history with <displayName/>.");
let caption;
if ((room.getJoinedMemberCount() + room.getInvitedMemberCount()) === 2) {
if (isLocalRoom) {
introMessage = _t("Send your first message to invite <displayName/> to chat");
} else if ((room.getJoinedMemberCount() + room.getInvitedMemberCount()) === 2) {
caption = _t("Only the two of you are in this conversation, unless either of you invites anyone to join.");
}
@ -75,7 +84,7 @@ const NewRoomIntro = () => {
<h2>{ room.name }</h2>
<p>{ _t("This is the beginning of your direct message history with <displayName/>.", {}, {
<p>{ _t(introMessage, {}, {
displayName: () => <b>{ displayName }</b>,
}) }</p>
{ caption && <p>{ caption }</p> }
@ -200,7 +209,7 @@ const NewRoomIntro = () => {
);
let subButton;
if (room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.get())) {
if (room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.get()) && !isLocalRoom) {
subButton = (
<AccessibleButton kind='link_inline' onClick={openRoomSettings}>{ _t("Enable encryption in settings.") }</AccessibleButton>
);

View file

@ -65,6 +65,8 @@ interface IProps {
appsShown: boolean;
searchInfo: ISearchInfo;
excludedRightPanelPhaseButtons?: Array<RightPanelPhases>;
showButtons?: boolean;
enableRoomOptionsMenu?: boolean;
}
interface IState {
@ -76,6 +78,8 @@ export default class RoomHeader extends React.Component<IProps, IState> {
editing: false,
inRoom: false,
excludedRightPanelPhaseButtons: [],
showButtons: true,
enableRoomOptionsMenu: true,
};
static contextType = RoomContext;
@ -130,81 +134,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
this.setState({ contextMenuPosition: null });
};
public render() {
let searchStatus = null;
// don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount.
if (this.props.searchInfo &&
this.props.searchInfo.searchCount !== undefined &&
this.props.searchInfo.searchCount !== null) {
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;
{ _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) }
</div>;
}
// XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'...
let settingsHint = false;
const members = this.props.room ? this.props.room.getJoinedMembers() : undefined;
if (members) {
if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) {
const nameEvent = this.props.room.currentState.getStateEvents('m.room.name', '');
if (!nameEvent || !nameEvent.getContent().name) {
settingsHint = true;
}
}
}
let oobName = _t("Join Room");
if (this.props.oobData && this.props.oobData.name) {
oobName = this.props.oobData.name;
}
let contextMenu: JSX.Element;
if (this.state.contextMenuPosition && this.props.room) {
contextMenu = (
<RoomContextMenu
{...contextMenuBelow(this.state.contextMenuPosition)}
room={this.props.room}
onFinished={this.onContextMenuCloseClick}
/>
);
}
const textClasses = classNames('mx_RoomHeader_nametext', { mx_RoomHeader_settingsHint: settingsHint });
const name = (
<ContextMenuTooltipButton
className="mx_RoomHeader_name"
onClick={this.onContextMenuOpenClick}
isExpanded={!!this.state.contextMenuPosition}
title={_t("Room options")}
>
<RoomName room={this.props.room}>
{ (name) => {
const roomName = name || oobName;
return <div dir="auto" className={textClasses} title={roomName}>{ roomName }</div>;
} }
</RoomName>
{ this.props.room && <div className="mx_RoomHeader_chevron" /> }
{ contextMenu }
</ContextMenuTooltipButton>
);
const topicElement = <RoomTopic
room={this.props.room}
className="mx_RoomHeader_topic"
/>;
let roomAvatar;
if (this.props.room) {
roomAvatar = <DecoratedRoomAvatar
room={this.props.room}
avatarSize={24}
oobData={this.props.oobData}
viewAvatarOnClick={true}
/>;
}
private renderButtons(): JSX.Element[] {
const buttons: JSX.Element[] = [];
if (this.props.inRoom &&
@ -269,10 +199,105 @@ export default class RoomHeader extends React.Component<IProps, IState> {
buttons.push(inviteButton);
}
const rightRow =
<div className="mx_RoomHeader_buttons">
{ buttons }
return buttons;
}
private renderName(oobName) {
let contextMenu: JSX.Element;
if (this.state.contextMenuPosition && this.props.room) {
contextMenu = (
<RoomContextMenu
{...contextMenuBelow(this.state.contextMenuPosition)}
room={this.props.room}
onFinished={this.onContextMenuCloseClick}
/>
);
}
// XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'...
let settingsHint = false;
const members = this.props.room ? this.props.room.getJoinedMembers() : undefined;
if (members) {
if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) {
const nameEvent = this.props.room.currentState.getStateEvents('m.room.name', '');
if (!nameEvent || !nameEvent.getContent().name) {
settingsHint = true;
}
}
}
const textClasses = classNames('mx_RoomHeader_nametext', { mx_RoomHeader_settingsHint: settingsHint });
const roomName = <RoomName room={this.props.room}>
{ (name) => {
const roomName = name || oobName;
return <div dir="auto" className={textClasses} title={roomName}>{ roomName }</div>;
} }
</RoomName>;
if (this.props.enableRoomOptionsMenu) {
return (
<ContextMenuTooltipButton
className="mx_RoomHeader_name"
onClick={this.onContextMenuOpenClick}
isExpanded={!!this.state.contextMenuPosition}
title={_t("Room options")}
>
{ roomName }
{ this.props.room && <div className="mx_RoomHeader_chevron" /> }
{ contextMenu }
</ContextMenuTooltipButton>
);
}
return <div className="mx_RoomHeader_name mx_RoomHeader_name--textonly">
{ roomName }
</div>;
}
public render() {
let searchStatus = null;
// don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount.
if (this.props.searchInfo &&
this.props.searchInfo.searchCount !== undefined &&
this.props.searchInfo.searchCount !== null) {
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;
{ _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) }
</div>;
}
let oobName = _t("Join Room");
if (this.props.oobData && this.props.oobData.name) {
oobName = this.props.oobData.name;
}
const name = this.renderName(oobName);
const topicElement = <RoomTopic
room={this.props.room}
className="mx_RoomHeader_topic"
/>;
let roomAvatar;
if (this.props.room) {
roomAvatar = <DecoratedRoomAvatar
room={this.props.room}
avatarSize={24}
oobData={this.props.oobData}
viewAvatarOnClick={true}
/>;
}
let buttons;
if (this.props.showButtons) {
buttons = <React.Fragment>
<div className="mx_RoomHeader_buttons">
{ this.renderButtons() }
</div>
<RoomHeaderButtons room={this.props.room} excludedRightPanelPhaseButtons={this.props.excludedRightPanelPhaseButtons} />
</React.Fragment>;
}
const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;
@ -294,8 +319,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
{ searchStatus }
{ topicElement }
{ betaPill }
{ rightRow }
<RoomHeaderButtons room={this.props.room} excludedRightPanelPhaseButtons={this.props.excludedRightPanelPhaseButtons} />
{ buttons }
</div>
<RoomLiveShareWarning roomId={this.props.room.roomId} />
</div>

View file

@ -1730,8 +1730,9 @@
"Code block": "Code block",
"Quote": "Quote",
"Insert link": "Insert link",
"Only the two of you are in this conversation, unless either of you invites anyone to join.": "Only the two of you are in this conversation, unless either of you invites anyone to join.",
"This is the beginning of your direct message history with <displayName/>.": "This is the beginning of your direct message history with <displayName/>.",
"Send your first message to invite <displayName/> to chat": "Send your first message to invite <displayName/> to chat",
"Only the two of you are in this conversation, unless either of you invites anyone to join.": "Only the two of you are in this conversation, unless either of you invites anyone to join.",
"Topic: %(topic)s (<a>edit</a>)": "Topic: %(topic)s (<a>edit</a>)",
"Topic: %(topic)s ": "Topic: %(topic)s ",
"<a>Add a topic</a> to help people know what it is about.": "<a>Add a topic</a> to help people know what it is about.",
@ -1771,15 +1772,15 @@
"Room %(name)s": "Room %(name)s",
"Recently visited rooms": "Recently visited rooms",
"No recently visited rooms": "No recently visited rooms",
"(~%(count)s results)|other": "(~%(count)s results)",
"(~%(count)s results)|one": "(~%(count)s result)",
"Join Room": "Join Room",
"Room options": "Room options",
"Forget room": "Forget room",
"Hide Widgets": "Hide Widgets",
"Show Widgets": "Show Widgets",
"Search": "Search",
"Invite": "Invite",
"Room options": "Room options",
"(~%(count)s results)|other": "(~%(count)s results)",
"(~%(count)s results)|one": "(~%(count)s result)",
"Join Room": "Join Room",
"Video rooms are a beta feature": "Video rooms are a beta feature",
"Video room": "Video room",
"Public space": "Public space",
@ -2102,6 +2103,7 @@
"View Source": "View Source",
"Some encryption parameters have been changed.": "Some encryption parameters have been changed.",
"Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.",
"Messages in this chat will be end-to-end encrypted.": "Messages in this chat will be end-to-end encrypted.",
"Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.",
"Encryption enabled": "Encryption enabled",
"Ignored attempt to disable encryption": "Ignored attempt to disable encryption",

View file

@ -16,6 +16,7 @@ limitations under the License.
import { MatrixClientPeg } from "../MatrixClientPeg";
import SettingsStore from "../settings/SettingsStore";
import { isLocalRoom } from "../utils/localRoom/isLocalRoom";
import Timer from "../utils/Timer";
const TYPING_USER_TIMEOUT = 10000;
@ -64,6 +65,9 @@ export default class TypingStore {
* @param {boolean} isTyping Whether the user is typing or not.
*/
public setSelfTyping(roomId: string, threadId: string | null, isTyping: boolean): void {
// No typing notifications for local rooms
if (isLocalRoom(roomId)) return;
if (!SettingsStore.getValue('sendTypingNotifications')) return;
if (SettingsStore.getValue('lowBandwidth')) return;
// Disable typing notification for threads for the initial launch

View file

@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import CallHandler from "../../../CallHandler";
import { RoomListCustomisations } from "../../../customisations/RoomList";
import { LocalRoom } from "../../../models/LocalRoom";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import VoipUserMapper from "../../../VoipUserMapper";
export class VisibilityProvider {
@ -55,7 +55,7 @@ export class VisibilityProvider {
return false;
}
if (room instanceof LocalRoom) {
if (isLocalRoom(room)) {
// local rooms shouldn't show up anywhere
return false;
}

View file

@ -0,0 +1,25 @@
/*
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";
// Wrap DOM event handlers with stopPropagation and preventDefault
export const preventDefaultWrapper =
<T extends React.BaseSyntheticEvent = React.BaseSyntheticEvent>(callback: () => void) => (e?: T) => {
e?.stopPropagation();
e?.preventDefault();
callback();
};

View file

@ -0,0 +1,26 @@
/*
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/matrix";
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../models/LocalRoom";
export function isLocalRoom(roomOrID: Room|string): boolean {
if (typeof roomOrID === "string") {
return roomOrID.startsWith(LOCAL_ROOM_ID_PREFIX);
}
return roomOrID instanceof LocalRoom;
}

View file

@ -35,7 +35,7 @@ export const useMap = ({
interactive,
bodyId,
onError,
}: UseMapProps): MapLibreMap => {
}: UseMapProps): MapLibreMap | undefined => {
const [map, setMap] = useState<MapLibreMap>();
useEffect(

102
test/Avatar-test.ts Normal file
View file

@ -0,0 +1,102 @@
/*
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 { mocked } from "jest-mock";
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { avatarUrlForRoom } from "../src/Avatar";
import { Media, mediaFromMxc } from "../src/customisations/Media";
import DMRoomMap from "../src/utils/DMRoomMap";
jest.mock("../src/customisations/Media", () => ({
mediaFromMxc: jest.fn(),
}));
const roomId = "!room:example.com";
const avatarUrl1 = "https://example.com/avatar1";
const avatarUrl2 = "https://example.com/avatar2";
describe("avatarUrlForRoom", () => {
let getThumbnailOfSourceHttp: jest.Mock;
let room: Room;
let roomMember: RoomMember;
let dmRoomMap: DMRoomMap;
beforeEach(() => {
getThumbnailOfSourceHttp = jest.fn();
mocked(mediaFromMxc).mockImplementation((): Media => {
return {
getThumbnailOfSourceHttp,
} as unknown as Media;
});
room = {
roomId,
getMxcAvatarUrl: jest.fn(),
isSpaceRoom: jest.fn(),
getAvatarFallbackMember: jest.fn(),
} as unknown as Room;
dmRoomMap = {
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
roomMember = {
getMxcAvatarUrl: jest.fn(),
} as unknown as RoomMember;
});
it("should return null for a null room", () => {
expect(avatarUrlForRoom(null, 128, 128)).toBeNull();
});
it("should return the HTTP source if the room provides a MXC url", () => {
mocked(room.getMxcAvatarUrl).mockReturnValue(avatarUrl1);
getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
});
it("should return null for a space room", () => {
mocked(room.isSpaceRoom).mockReturnValue(true);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
it("should return null if the room is not a DM", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue(null);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
expect(dmRoomMap.getUserIdForRoomId).toHaveBeenCalledWith(roomId);
});
it("should return null if there is no other member in the room", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(null);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
it("should return null if the other member has no avatar URL", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
it("should return the other member's avatar URL", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember);
mocked(roomMember.getMxcAvatarUrl).mockReturnValue(avatarUrl2);
getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
});
});

119
test/Unread-test.ts Normal file
View file

@ -0,0 +1,119 @@
/*
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 { mocked } from "jest-mock";
import {
MatrixEvent,
EventType,
MsgType,
} from "matrix-js-sdk/src/matrix";
import { haveRendererForEvent } from "../src/events/EventTileFactory";
import { getMockClientWithEventEmitter, makeBeaconEvent, mockClientMethodsUser } from "./test-utils";
import { eventTriggersUnreadCount } from "../src/Unread";
jest.mock("../src/events/EventTileFactory", () => ({
haveRendererForEvent: jest.fn(),
}));
describe('eventTriggersUnreadCount()', () => {
const aliceId = '@alice:server.org';
const bobId = '@bob:server.org';
// mock user credentials
getMockClientWithEventEmitter({
...mockClientMethodsUser(bobId),
});
// setup events
const alicesMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: aliceId,
content: {
msgtype: MsgType.Text,
body: 'Hello from Alice',
},
});
const bobsMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: bobId,
content: {
msgtype: MsgType.Text,
body: 'Hello from Bob',
},
});
const redactedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: aliceId,
});
redactedEvent.makeRedacted(redactedEvent);
beforeEach(() => {
jest.clearAllMocks();
mocked(haveRendererForEvent).mockClear().mockReturnValue(false);
});
it('returns false when the event was sent by the current user', () => {
expect(eventTriggersUnreadCount(bobsMessage)).toBe(false);
// returned early before checking renderer
expect(haveRendererForEvent).not.toHaveBeenCalled();
});
it('returns false for a redacted event', () => {
expect(eventTriggersUnreadCount(redactedEvent)).toBe(false);
// returned early before checking renderer
expect(haveRendererForEvent).not.toHaveBeenCalled();
});
it('returns false for an event without a renderer', () => {
mocked(haveRendererForEvent).mockReturnValue(false);
expect(eventTriggersUnreadCount(alicesMessage)).toBe(false);
expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false);
});
it('returns true for an event with a renderer', () => {
mocked(haveRendererForEvent).mockReturnValue(true);
expect(eventTriggersUnreadCount(alicesMessage)).toBe(true);
expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false);
});
it('returns false for beacon locations', () => {
const beaconLocationEvent = makeBeaconEvent(aliceId);
expect(eventTriggersUnreadCount(beaconLocationEvent)).toBe(false);
expect(haveRendererForEvent).not.toHaveBeenCalled();
});
const noUnreadEventTypes = [
EventType.RoomMember,
EventType.RoomThirdPartyInvite,
EventType.CallAnswer,
EventType.CallHangup,
EventType.RoomAliases,
EventType.RoomCanonicalAlias,
EventType.RoomServerAcl,
];
it.each(noUnreadEventTypes)('returns false without checking for renderer for events with type %s', (eventType) => {
const event = new MatrixEvent({
type: eventType,
sender: aliceId,
});
expect(eventTriggersUnreadCount(event)).toBe(false);
expect(haveRendererForEvent).not.toHaveBeenCalled();
});
});

View file

@ -16,6 +16,9 @@ limitations under the License.
import EmojiProvider from '../../src/autocomplete/EmojiProvider';
import { mkStubRoom } from '../test-utils/test-utils';
import { add } from "../../src/emojipicker/recent";
import { stubClient } from "../test-utils";
import { MatrixClientPeg } from '../../src/MatrixClientPeg';
const EMOJI_SHORTCODES = [
":+1",
@ -42,6 +45,8 @@ const TOO_SHORT_EMOJI_SHORTCODE = [
describe('EmojiProvider', function() {
const testRoom = mkStubRoom(undefined, undefined, undefined);
stubClient();
MatrixClientPeg.get();
it.each(EMOJI_SHORTCODES)('Returns consistent results after final colon %s', async function(emojiShortcode) {
const ep = new EmojiProvider(testRoom);
@ -64,4 +69,21 @@ describe('EmojiProvider', function() {
expect(completions[0].completion).toEqual(expectedEmoji);
});
it('Returns correct autocompletion based on recently used emoji', async function() {
add("😘"); //kissing_heart
add("😘");
add("😚"); //kissing_closed_eyes
const emojiProvider = new EmojiProvider(null);
let completionsList = await emojiProvider.getCompletions(":kis", { beginning: true, end: 3, start: 3 });
expect(completionsList[0].component.props.title).toEqual(":kissing_heart:");
expect(completionsList[1].component.props.title).toEqual(":kissing_closed_eyes:");
completionsList = await emojiProvider.getCompletions(":kissing_c", { beginning: true, end: 3, start: 3 });
expect(completionsList[0].component.props.title).toEqual(":kissing_closed_eyes:");
completionsList = await emojiProvider.getCompletions(":so", { beginning: true, end: 2, start: 2 });
expect(completionsList[0].component.props.title).toEqual(":sob:");
});
});

View file

@ -0,0 +1,47 @@
/*
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 { render, screen } from "@testing-library/react";
import { RoomStatusBarUnsentMessages } from "../../../src/components/structures/RoomStatusBarUnsentMessages";
import { StaticNotificationState } from "../../../src/stores/notifications/StaticNotificationState";
describe("RoomStatusBarUnsentMessages", () => {
const title = "test title";
const description = "test description";
const buttonsText = "test buttons";
const buttons = <div>{ buttonsText }</div>;
beforeEach(() => {
render(
<RoomStatusBarUnsentMessages
title={title}
description={description}
buttons={buttons}
notificationState={StaticNotificationState.RED_EXCLAMATION}
/>,
);
});
it("should render the values passed as props", () => {
screen.getByText(title);
screen.getByText(description);
screen.getByText(buttonsText);
// notification state
screen.getByText("!");
});
});

View file

@ -27,6 +27,7 @@ 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,
@ -169,5 +170,30 @@ describe('<BeaconListItem />', () => {
expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago');
});
});
describe('interactions', () => {
it('does not call onClick handler when clicking share button', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const onClick = jest.fn();
const component = getComponent({ beacon, onClick });
act(() => {
findByTestId(component, 'open-location-in-osm').at(0).simulate('click');
});
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 });
act(() => {
// click the beacon name
component.find('.mx_BeaconStatus_description').simulate('click');
});
expect(onClick).toHaveBeenCalled();
});
});
});
});

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { mount, ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import {
MatrixClient,
@ -28,15 +28,18 @@ import maplibregl from 'maplibre-gl';
import BeaconViewDialog from '../../../../src/components/views/beacon/BeaconViewDialog';
import {
findByAttr,
findByTestId,
getMockClientWithEventEmitter,
makeBeaconEvent,
makeBeaconInfoEvent,
makeRoomWithBeacons,
makeRoomWithStateEvents,
} from '../../../test-utils';
import { TILE_SERVER_WK_KEY } from '../../../../src/utils/WellKnownUtils';
import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore';
import { BeaconDisplayStatus } from '../../../../src/components/views/beacon/displayStatus';
import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem';
describe('<BeaconViewDialog />', () => {
// 14.03.2022 16:15
@ -89,13 +92,18 @@ describe('<BeaconViewDialog />', () => {
const getComponent = (props = {}) =>
mount(<BeaconViewDialog {...defaultProps} {...props} />);
const openSidebar = (component: ReactWrapper) => act(() => {
findByTestId(component, 'beacon-view-dialog-open-sidebar').at(0).simulate('click');
component.setProps({});
});
beforeAll(() => {
maplibregl.AttributionControl = jest.fn();
});
beforeEach(() => {
jest.spyOn(OwnBeaconStore.instance, 'getLiveBeaconIds').mockRestore();
jest.spyOn(global.Date, 'now').mockReturnValue(now);
jest.clearAllMocks();
});
@ -225,10 +233,7 @@ describe('<BeaconViewDialog />', () => {
beacon.addLocations([location1]);
const component = getComponent();
act(() => {
findByTestId(component, 'beacon-view-dialog-open-sidebar').at(0).simulate('click');
component.setProps({});
});
openSidebar(component);
expect(component.find('DialogSidebar').length).toBeTruthy();
});
@ -240,20 +245,134 @@ describe('<BeaconViewDialog />', () => {
const component = getComponent();
// open the sidebar
act(() => {
findByTestId(component, 'beacon-view-dialog-open-sidebar').at(0).simulate('click');
component.setProps({});
});
openSidebar(component);
expect(component.find('DialogSidebar').length).toBeTruthy();
// now close it
act(() => {
findByTestId(component, 'dialog-sidebar-close').at(0).simulate('click');
findByAttr('data-testid')(component, 'dialog-sidebar-close').at(0).simulate('click');
component.setProps({});
});
expect(component.find('DialogSidebar').length).toBeFalsy();
});
});
describe('focused beacons', () => {
const beacon2Event = makeBeaconInfoEvent(bobId,
roomId,
{ isLive: true },
'$bob-room1-2',
);
const location2 = makeBeaconEvent(
bobId, { beaconInfoId: beacon2Event.getId(), geoUri: 'geo:33,22', timestamp: now + 1 },
);
const fitBoundsOptions = { maxZoom: 15, padding: 100 };
it('opens map with both beacons in view on first load without initialFocusedBeacon', () => {
const [beacon1, beacon2] = makeRoomWithBeacons(
roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2],
);
getComponent({ beacons: [beacon1, beacon2] });
// start centered on mid point between both beacons
expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 42, lon: 31.5 });
// only called once
expect(mockMap.setCenter).toHaveBeenCalledTimes(1);
// bounds fit both beacons, only called once
expect(mockMap.fitBounds).toHaveBeenCalledWith(new maplibregl.LngLatBounds(
[22, 33], [41, 51],
), fitBoundsOptions);
expect(mockMap.fitBounds).toHaveBeenCalledTimes(1);
});
it('opens map with both beacons in view on first load with an initially focused beacon', () => {
const [beacon1, beacon2] = makeRoomWithBeacons(
roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2],
);
getComponent({ beacons: [beacon1, beacon2], initialFocusedBeacon: beacon1 });
// start centered on initialFocusedBeacon
expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 41 });
// only called once
expect(mockMap.setCenter).toHaveBeenCalledTimes(1);
// bounds fit both beacons, only called once
expect(mockMap.fitBounds).toHaveBeenCalledWith(new maplibregl.LngLatBounds(
[22, 33], [41, 51],
), fitBoundsOptions);
expect(mockMap.fitBounds).toHaveBeenCalledTimes(1);
});
it('focuses on beacon location on sidebar list item click', () => {
const [beacon1, beacon2] = makeRoomWithBeacons(
roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2],
);
const component = getComponent({ beacons: [beacon1, beacon2] });
// reset call counts on map mocks after initial render
jest.clearAllMocks();
openSidebar(component);
act(() => {
// click on the first beacon in the list
component.find(BeaconListItem).at(0).simulate('click');
});
// centered on clicked beacon
expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 41 });
// only called once
expect(mockMap.setCenter).toHaveBeenCalledTimes(1);
// bounds fitted just to clicked beacon
expect(mockMap.fitBounds).toHaveBeenCalledWith(new maplibregl.LngLatBounds(
[41, 51], [41, 51],
), fitBoundsOptions);
expect(mockMap.fitBounds).toHaveBeenCalledTimes(1);
});
it('refocuses on same beacon when clicking list item again', () => {
// test the map responds to refocusing the same beacon
const [beacon1, beacon2] = makeRoomWithBeacons(
roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2],
);
const component = getComponent({ beacons: [beacon1, beacon2] });
// reset call counts on map mocks after initial render
jest.clearAllMocks();
openSidebar(component);
act(() => {
// click on the second beacon in the list
component.find(BeaconListItem).at(1).simulate('click');
});
const expectedBounds = new maplibregl.LngLatBounds(
[22, 33], [22, 33],
);
// date is mocked but this relies on timestamp, manually mock a tick
jest.spyOn(global.Date, 'now').mockReturnValue(now + 1);
act(() => {
// click on the second beacon in the list
component.find(BeaconListItem).at(1).simulate('click');
});
// centered on clicked beacon
expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 33, lon: 22 });
// bounds fitted just to clicked beacon
expect(mockMap.fitBounds).toHaveBeenCalledWith(expectedBounds, fitBoundsOptions);
// each called once per click
expect(mockMap.setCenter).toHaveBeenCalledTimes(2);
expect(mockMap.fitBounds).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -15,31 +15,88 @@ limitations under the License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import DialogSidebar from '../../../../src/components/views/beacon/DialogSidebar';
import { findByTestId } from '../../../test-utils';
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
import {
getMockClientWithEventEmitter,
makeBeaconEvent,
makeBeaconInfoEvent,
makeRoomWithBeacons,
mockClientMethodsUser,
} from '../../../test-utils';
describe('<DialogSidebar />', () => {
const defaultProps = {
beacons: [],
requestClose: jest.fn(),
onBeaconClick: jest.fn(),
};
const getComponent = (props = {}) =>
mount(<DialogSidebar {...defaultProps} {...props} />);
it('renders sidebar correctly', () => {
const component = getComponent();
expect(component).toMatchSnapshot();
const now = 1647270879403;
const roomId = '!room:server.org';
const aliceId = '@alice:server.org';
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(aliceId),
getRoom: jest.fn(),
});
const beaconEvent = makeBeaconInfoEvent(aliceId,
roomId,
{ isLive: true, timestamp: now },
'$alice-room1-1',
);
const location1 = makeBeaconEvent(
aliceId, { beaconInfoId: beaconEvent.getId(), geoUri: 'geo:51,41', timestamp: now },
);
const getComponent = (props = {}) => (
<MatrixClientContext.Provider value={client}>
<DialogSidebar {...defaultProps} {...props} />);
</MatrixClientContext.Provider>);
beforeEach(() => {
// mock now so time based text in snapshots is stable
jest.spyOn(Date, 'now').mockReturnValue(now);
});
afterAll(() => {
jest.spyOn(Date, 'now').mockRestore();
});
it('renders sidebar correctly without beacons', () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it('renders sidebar correctly with beacons', () => {
const [beacon] = makeRoomWithBeacons(roomId, client, [beaconEvent], [location1]);
const { container } = render(getComponent({ beacons: [beacon] }));
expect(container).toMatchSnapshot();
});
it('calls on beacon click', () => {
const onBeaconClick = jest.fn();
const [beacon] = makeRoomWithBeacons(roomId, client, [beaconEvent], [location1]);
const { container } = render(getComponent({ beacons: [beacon], onBeaconClick }));
act(() => {
const [listItem] = container.getElementsByClassName('mx_BeaconListItem');
fireEvent.click(listItem);
});
expect(onBeaconClick).toHaveBeenCalled();
});
it('closes on close button click', () => {
const requestClose = jest.fn();
const component = getComponent({ requestClose });
const { getByTestId } = render(getComponent({ requestClose }));
act(() => {
findByTestId(component, 'dialog-sidebar-close').at(0).simulate('click');
fireEvent.click(getByTestId('dialog-sidebar-close'));
});
expect(requestClose).toHaveBeenCalled();
});

View file

@ -1,3 +1,3 @@
// 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 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><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`] = `"<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>"`;

View file

@ -1,53 +1,144 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DialogSidebar /> renders sidebar correctly 1`] = `
<DialogSidebar
beacons={Array []}
requestClose={[MockFunction]}
>
exports[`<DialogSidebar /> renders sidebar correctly with beacons 1`] = `
<div>
<div
className="mx_DialogSidebar"
class="mx_DialogSidebar"
>
<div
className="mx_DialogSidebar_header"
class="mx_DialogSidebar_header"
>
<Heading
size="h4"
<h4
class="mx_Heading_h4"
>
<h4
className="mx_Heading_h4"
>
View List
</h4>
</Heading>
<AccessibleButton
className="mx_DialogSidebar_closeButton"
data-test-id="dialog-sidebar-close"
element="div"
onClick={[MockFunction]}
View List
</h4>
<div
class="mx_AccessibleButton mx_DialogSidebar_closeButton"
data-testid="dialog-sidebar-close"
role="button"
tabIndex={0}
tabindex="0"
title="Close sidebar"
>
<div
className="mx_AccessibleButton mx_DialogSidebar_closeButton"
data-test-id="dialog-sidebar-close"
onClick={[MockFunction]}
onKeyDown={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Close sidebar"
>
<div
className="mx_DialogSidebar_closeButtonIcon"
/>
</div>
</AccessibleButton>
class="mx_DialogSidebar_closeButtonIcon"
/>
</div>
</div>
<ol
className="mx_DialogSidebar_list"
class="mx_DialogSidebar_list"
>
<li
class="mx_BeaconListItem"
>
<span
class="mx_BaseAvatar mx_BeaconListItem_avatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 20.8px; width: 32px; line-height: 32px;"
/>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
src=""
style="width: 32px; height: 32px;"
/>
</span>
<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:server.org
</span>
<span
class="mx_BeaconStatus_expiryTime"
>
Live until 16:14
</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&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>
</ol>
</div>
);
</div>
`;
exports[`<DialogSidebar /> renders sidebar correctly without beacons 1`] = `
<div>
<div
class="mx_DialogSidebar"
>
<div
class="mx_DialogSidebar_header"
>
<h4
class="mx_Heading_h4"
>
View List
</h4>
<div
class="mx_AccessibleButton mx_DialogSidebar_closeButton"
data-testid="dialog-sidebar-close"
role="button"
tabindex="0"
title="Close sidebar"
>
<div
class="mx_DialogSidebar_closeButtonIcon"
/>
</div>
</div>
<ol
class="mx_DialogSidebar_list"
/>
</div>
</DialogSidebar>
);
</div>
`;

View file

@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mount } from "enzyme";
import { IProtocol, IPublicRoomsChunkRoom, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import { mount, ReactWrapper } from "enzyme";
import { mocked } from "jest-mock";
import { IProtocol, IPublicRoomsChunkRoom, MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import React from "react";
import { act } from "react-dom/test-utils";
@ -23,7 +24,15 @@ import sanitizeHtml from "sanitize-html";
import SpotlightDialog, { Filter } from "../../../../src/components/views/dialogs/spotlight/SpotlightDialog";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { stubClient } from "../../../test-utils";
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { mkRoom, stubClient } from "../../../test-utils";
jest.mock("../../../../src/utils/direct-messages", () => ({
// @ts-ignore
...jest.requireActual("../../../../src/utils/direct-messages"),
startDmOnFirstMessage: jest.fn(),
}));
interface IUserChunkMember {
user_id: string;
@ -110,10 +119,23 @@ describe("Spotlight Dialog", () => {
guest_can_join: false,
};
beforeEach(() => {
mockClient({ rooms: [testPublicRoom], users: [testPerson] });
});
let testRoom: Room;
let testLocalRoom: LocalRoom;
let mockedClient: MatrixClient;
beforeEach(() => {
mockedClient = mockClient({ rooms: [testPublicRoom], users: [testPerson] });
testRoom = mkRoom(mockedClient, "!test23:example.com");
mocked(testRoom.getMyMembership).mockReturnValue("join");
testLocalRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test23", mockedClient, mockedClient.getUserId());
testLocalRoom.updateMyMembership("join");
mocked(mockedClient.getVisibleRooms).mockReturnValue([testRoom, testLocalRoom]);
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap);
});
describe("should apply filters supplied via props", () => {
it("without filter", async () => {
const wrapper = mount(
@ -289,4 +311,38 @@ describe("Spotlight Dialog", () => {
wrapper.unmount();
});
});
describe("searching for rooms", () => {
let wrapper: ReactWrapper;
let options: ReactWrapper;
beforeAll(async () => {
wrapper = mount(
<SpotlightDialog
initialText="test23"
onFinished={() => null} />,
);
await act(async () => {
await sleep(200);
});
wrapper.update();
const content = wrapper.find("#mx_SpotlightDialog_content");
options = content.find("div.mx_SpotlightDialog_option");
});
afterAll(() => {
wrapper.unmount();
});
it("should find Rooms", () => {
expect(options.length).toBe(3);
expect(options.first().text()).toContain(testRoom.name);
});
it("should not find LocalRooms", () => {
expect(options.length).toBe(3);
expect(options.first().text()).not.toContain(testLocalRoom.name);
});
});
});

View file

@ -0,0 +1,131 @@
/*
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 { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { render, screen } from '@testing-library/react';
import EncryptionEvent from "../../../../src/components/views/messages/EncryptionEvent";
import { createTestClient, mkMessage } from "../../../test-utils";
import { MatrixClientPeg } from '../../../../src/MatrixClientPeg';
import { LocalRoom } from '../../../../src/models/LocalRoom';
import DMRoomMap from '../../../../src/utils/DMRoomMap';
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
const renderEncryptionEvent = (client: MatrixClient, event: MatrixEvent) => {
render(<MatrixClientContext.Provider value={client}>
<EncryptionEvent mxEvent={event} />
</MatrixClientContext.Provider>);
};
const checkTexts = (title: string, subTitle: string) => {
screen.getByText(title);
screen.getByText(subTitle);
};
describe("EncryptionEvent", () => {
const roomId = "!room:example.com";
const algorithm = "m.megolm.v1.aes-sha2";
let client: MatrixClient;
let event: MatrixEvent;
beforeEach(() => {
jest.clearAllMocks();
client = createTestClient();
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
event = mkMessage({
event: true,
room: roomId,
user: client.getUserId(),
});
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap);
});
describe("for an encrypted room", () => {
beforeEach(() => {
event.event.content.algorithm = algorithm;
mocked(client.isRoomEncrypted).mockReturnValue(true);
const room = new Room(roomId, client, client.getUserId());
mocked(client.getRoom).mockReturnValue(room);
});
it("should show the expected texts", () => {
renderEncryptionEvent(client, event);
checkTexts(
"Encryption enabled",
"Messages in this room are end-to-end encrypted. "
+ "When people join, you can verify them in their profile, just tap on their avatar.",
);
});
describe("with same previous algorithm", () => {
beforeEach(() => {
jest.spyOn(event, "getPrevContent").mockReturnValue({
algorithm: algorithm,
});
});
it("should show the expected texts", () => {
renderEncryptionEvent(client, event);
checkTexts(
"Encryption enabled",
"Some encryption parameters have been changed.",
);
});
});
describe("with unknown algorithm", () => {
beforeEach(() => {
event.event.content.algorithm = "unknown";
});
it("should show the expected texts", () => {
renderEncryptionEvent(client, event);
checkTexts("Encryption enabled", "Ignored attempt to disable encryption");
});
});
});
describe("for an unencrypted room", () => {
beforeEach(() => {
mocked(client.isRoomEncrypted).mockReturnValue(false);
renderEncryptionEvent(client, event);
});
it("should show the expected texts", () => {
expect(client.isRoomEncrypted).toHaveBeenCalledWith(roomId);
checkTexts("Encryption not enabled", "The encryption used by this room isn't supported.");
});
});
describe("for an encrypted local room", () => {
beforeEach(() => {
event.event.content.algorithm = algorithm;
mocked(client.isRoomEncrypted).mockReturnValue(true);
const localRoom = new LocalRoom(roomId, client, client.getUserId());
mocked(client.getRoom).mockReturnValue(localRoom);
renderEncryptionEvent(client, event);
});
it("should show the expected texts", () => {
expect(client.isRoomEncrypted).toHaveBeenCalledWith(roomId);
checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted.");
});
});
});

View file

@ -0,0 +1,78 @@
/*
Copyright 2015 - 2022 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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 { render, screen } from "@testing-library/react";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { LocalRoom } from "../../../../src/models/LocalRoom";
import { createTestClient } from "../../../test-utils";
import RoomContext from "../../../../src/contexts/RoomContext";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import NewRoomIntro from "../../../../src/components/views/rooms/NewRoomIntro";
import { IRoomState } from "../../../../src/components/structures/RoomView";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { DirectoryMember } from "../../../../src/utils/direct-messages";
const renderNewRoomIntro = (client: MatrixClient, room: Room|LocalRoom) => {
render(
<MatrixClientContext.Provider value={client}>
<RoomContext.Provider value={{ room, roomId: room.roomId } as unknown as IRoomState}>
<NewRoomIntro />
</RoomContext.Provider>
</MatrixClientContext.Provider>,
);
};
describe("NewRoomIntro", () => {
let client: MatrixClient;
const roomId = "!room:example.com";
const userId = "@user:example.com";
beforeEach(() => {
client = createTestClient();
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
DMRoomMap.makeShared();
});
describe("for a DM Room", () => {
beforeEach(() => {
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(userId);
renderNewRoomIntro(client, new Room(roomId, client, client.getUserId()));
});
it("should render the expected intro", () => {
const expected = `This is the beginning of your direct message history with ${userId}.`;
screen.getByText((id, element) => element.tagName === "SPAN" && element.textContent === expected);
});
});
describe("for a DM LocalRoom", () => {
beforeEach(() => {
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(userId);
const localRoom = new LocalRoom(roomId, client, client.getUserId());
localRoom.targets.push(new DirectoryMember({ user_id: userId }));
renderNewRoomIntro(client, localRoom);
});
it("should render the expected intro", () => {
const expected = `Send your first message to invite ${userId} to chat`;
screen.getByText((id, element) => element.tagName === "SPAN" && element.textContent === expected);
});
});
});

View file

@ -127,7 +127,7 @@ describe('RoomHeader', () => {
it("hides call buttons when the room is tombstoned", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = render(room, {
const wrapper = render(room, {}, {
tombstone: mkEvent({
event: true,
type: "m.room.tombstone",
@ -142,6 +142,30 @@ describe('RoomHeader', () => {
expect(wrapper.find('[aria-label="Voice call"]').hostNodes()).toHaveLength(0);
expect(wrapper.find('[aria-label="Video call"]').hostNodes()).toHaveLength(0);
});
it("should render buttons if not passing showButtons (default true)", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = render(room);
expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(1);
});
it("should not render buttons if passing showButtons = false", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = render(room, { showButtons: false });
expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(0);
});
it("should render the room options context menu if not passing enableRoomOptionsMenu (default true)", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = render(room);
expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(1);
});
it("should not render the room options context menu if passing enableRoomOptionsMenu = false", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = render(room, { enableRoomOptionsMenu: false });
expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(0);
});
});
interface IRoomCreationInfo {
@ -185,25 +209,28 @@ function createRoom(info: IRoomCreationInfo) {
return room;
}
function render(room: Room, roomContext?: Partial<IRoomState>): ReactWrapper {
function render(room: Room, propsOverride = {}, roomContext?: Partial<IRoomState>): ReactWrapper {
const props = {
room,
inRoom: true,
onSearchClick: () => {},
onInviteClick: null,
onForgetClick: () => {},
onCallPlaced: (_type) => { },
onAppsClick: () => {},
e2eStatus: E2EStatus.Normal,
appsShown: true,
searchInfo: {
searchTerm: "",
searchScope: SearchScope.Room,
searchCount: 0,
},
...propsOverride,
};
return mount((
<RoomContext.Provider value={{ ...roomContext, room } as IRoomState}>
<RoomHeader
room={room}
inRoom={true}
onSearchClick={() => {}}
onInviteClick={null}
onForgetClick={() => {}}
onCallPlaced={(_type) => { }}
onAppsClick={() => {}}
e2eStatus={E2EStatus.Normal}
appsShown={true}
searchInfo={{
searchTerm: "",
searchScope: SearchScope.Room,
searchCount: 0,
}}
/>
<RoomHeader {...props} />
</RoomContext.Provider>
));
}

View file

@ -1,31 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 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 request = require('request-promise-native');
import * as cheerio from 'cheerio';
import * as url from "url";
export const approveConsent = async function(consentUrl: string): Promise<void> {
const body = await request.get(consentUrl);
const doc = cheerio.load(body);
const v = doc("input[name=v]").val();
const u = doc("input[name=u]").val();
const h = doc("input[name=h]").val();
const formAction = doc("form").attr("action");
const absAction = url.resolve(consentUrl, formAction);
await request.post(absAction).form({ v, u, h });
};

View file

@ -1,90 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 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 request = require('request-promise-native');
import * as crypto from 'crypto';
import { RestSession } from './session';
import { RestMultiSession } from './multi';
export interface Credentials {
accessToken: string;
homeServer: string;
userId: string;
deviceId: string;
hsUrl: string;
}
export class RestSessionCreator {
constructor(private readonly hsUrl: string, private readonly regSecret: string) {}
public async createSessionRange(usernames: string[], password: string,
groupName: string): Promise<RestMultiSession> {
const sessionPromises = usernames.map((username) => this.createSession(username, password));
const sessions = await Promise.all(sessionPromises);
return new RestMultiSession(sessions, groupName);
}
public async createSession(username: string, password: string): Promise<RestSession> {
await this.register(username, password);
console.log(` * created REST user ${username} ... done`);
const authResult = await this.authenticate(username, password);
return new RestSession(authResult);
}
private async register(username: string, password: string): Promise<void> {
// get a nonce
const regUrl = `${this.hsUrl}/_synapse/admin/v1/register`;
const nonceResp = await request.get({ uri: regUrl, json: true });
const mac = crypto.createHmac('sha1', this.regSecret).update(
`${nonceResp.nonce}\0${username}\0${password}\0notadmin`,
).digest('hex');
await request.post({
uri: regUrl,
json: true,
body: {
nonce: nonceResp.nonce,
username,
password,
mac,
admin: false,
},
});
}
private async authenticate(username: string, password: string): Promise<Credentials> {
const requestBody = {
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
"user": username,
},
"password": password,
};
const url = `${this.hsUrl}/_matrix/client/r0/login`;
const responseBody = await request.post({ url, json: true, body: requestBody });
return {
accessToken: responseBody.access_token,
homeServer: responseBody.home_server,
userId: responseBody.user_id,
deviceId: responseBody.device_id,
hsUrl: this.hsUrl,
};
}
}

View file

@ -1,93 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 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 { Logger } from '../logger';
import { RestSession } from "./session";
import { RestRoom } from "./room";
export class RestMultiSession {
readonly log: Logger;
constructor(public readonly sessions: RestSession[], groupName: string) {
this.log = new Logger(groupName);
}
public slice(groupName: string, start: number, end?: number): RestMultiSession {
return new RestMultiSession(this.sessions.slice(start, end), groupName);
}
public pop(userName: string): RestSession {
const idx = this.sessions.findIndex((s) => s.userName() === userName);
if (idx === -1) {
throw new Error(`user ${userName} not found`);
}
const session = this.sessions.splice(idx, 1)[0];
return session;
}
public async setDisplayName(fn: (s: RestSession) => string): Promise<void> {
this.log.step("set their display name");
await Promise.all(this.sessions.map(async (s: RestSession) => {
s.log.mute();
await s.setDisplayName(fn(s));
s.log.unmute();
}));
this.log.done();
}
public async join(roomIdOrAlias: string): Promise<RestMultiRoom> {
this.log.step(`join ${roomIdOrAlias}`);
const rooms = await Promise.all(this.sessions.map(async (s) => {
s.log.mute();
const room = await s.join(roomIdOrAlias);
s.log.unmute();
return room;
}));
this.log.done();
return new RestMultiRoom(rooms, roomIdOrAlias, this.log);
}
public room(roomIdOrAlias: string): RestMultiRoom {
const rooms = this.sessions.map(s => s.room(roomIdOrAlias));
return new RestMultiRoom(rooms, roomIdOrAlias, this.log);
}
}
class RestMultiRoom {
constructor(public readonly rooms: RestRoom[], private readonly roomIdOrAlias: string,
private readonly log: Logger) {}
public async talk(message: string): Promise<void> {
this.log.step(`say "${message}" in ${this.roomIdOrAlias}`);
await Promise.all(this.rooms.map(async (r: RestRoom) => {
r.log.mute();
await r.talk(message);
r.log.unmute();
}));
this.log.done();
}
public async leave() {
this.log.step(`leave ${this.roomIdOrAlias}`);
await Promise.all(this.rooms.map(async (r) => {
r.log.mute();
await r.leave();
r.log.unmute();
}));
this.log.done();
}
}

View file

@ -1,43 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 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 uuidv4 = require('uuid/v4');
import { RestSession } from "./session";
import { Logger } from "../logger";
/* no pun intended */
export class RestRoom {
constructor(readonly session: RestSession, readonly roomId: string, readonly log: Logger) {}
async talk(message: string): Promise<string> {
this.log.step(`says "${message}" in ${this.roomId}`);
const txId = uuidv4();
const { event_id: eventId } = await this.session.put(`/rooms/${this.roomId}/send/m.room.message/${txId}`, {
"msgtype": "m.text",
"body": message,
});
this.log.done();
return eventId;
}
async leave(): Promise<void> {
this.log.step(`leaves ${this.roomId}`);
await this.session.post(`/rooms/${this.roomId}/leave`);
this.log.done();
}
}

View file

@ -1,138 +0,0 @@
/*
Copyright 2018 New Vector Ltd
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 request = require('request-promise-native');
import { Logger } from '../logger';
import { RestRoom } from './room';
import { approveConsent } from './consent';
import { Credentials } from "./creator";
interface RoomOptions {
invite?: string;
public?: boolean;
topic?: string;
dm?: boolean;
}
export class RestSession {
private _displayName: string = null;
private readonly rooms: Record<string, RestRoom> = {};
readonly log: Logger;
constructor(private readonly credentials: Credentials) {
this.log = new Logger(credentials.userId);
}
userId(): string {
return this.credentials.userId;
}
userName(): string {
return this.credentials.userId.split(":")[0].slice(1);
}
displayName(): string {
return this._displayName;
}
async setDisplayName(displayName: string): Promise<void> {
this.log.step(`sets their display name to ${displayName}`);
this._displayName = displayName;
await this.put(`/profile/${this.credentials.userId}/displayname`, {
displayname: displayName,
});
this.log.done();
}
async join(roomIdOrAlias: string): Promise<RestRoom> {
this.log.step(`joins ${roomIdOrAlias}`);
const roomId = (await this.post(`/join/${encodeURIComponent(roomIdOrAlias)}`)).room_id;
this.log.done();
const room = new RestRoom(this, roomId, this.log);
this.rooms[roomId] = room;
this.rooms[roomIdOrAlias] = room;
return room;
}
room(roomIdOrAlias: string): RestRoom {
if (this.rooms.hasOwnProperty(roomIdOrAlias)) {
return this.rooms[roomIdOrAlias];
} else {
throw new Error(`${this.credentials.userId} is not in ${roomIdOrAlias}`);
}
}
async createRoom(name: string, options: RoomOptions): Promise<RestRoom> {
this.log.step(`creates room ${name}`);
const body = {
name,
};
if (options.invite) {
body['invite'] = options.invite;
}
if (options.public) {
body['visibility'] = "public";
} else {
body['visibility'] = "private";
}
if (options.dm) {
body['is_direct'] = true;
}
if (options.topic) {
body['topic'] = options.topic;
}
const roomId = (await this.post(`/createRoom`, body)).room_id;
this.log.done();
return new RestRoom(this, roomId, this.log);
}
post(csApiPath: string, body?: any): Promise<any> {
return this.request("POST", csApiPath, body);
}
put(csApiPath: string, body?: any): Promise<any> {
return this.request("PUT", csApiPath, body);
}
async request(method: string, csApiPath: string, body?: any): Promise<any> {
try {
return await request({
url: `${this.credentials.hsUrl}/_matrix/client/r0${csApiPath}`,
method,
headers: {
"Authorization": `Bearer ${this.credentials.accessToken}`,
},
json: true,
body,
});
} catch (err) {
if (!err.response) {
throw err;
}
const responseBody = err.response.body;
if (responseBody.errcode === 'M_CONSENT_NOT_GIVEN') {
await approveConsent(responseBody.consent_uri);
return this.request(method, csApiPath, body);
} else if (responseBody && responseBody.error) {
throw new Error(`${method} ${csApiPath}: ${responseBody.error}`);
} else {
throw new Error(`${method} ${csApiPath}: ${err.response.statusCode}`);
}
}
}
}

View file

@ -15,18 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { range } from './util';
import { signup } from './usecases/signup';
import { toastScenarios } from './scenarios/toast';
import { lazyLoadingScenarios } from './scenarios/lazy-loading';
import { e2eEncryptionScenarios } from './scenarios/e2e-encryption';
import { ElementSession } from "./session";
import { RestSessionCreator } from "./rest/creator";
import { RestMultiSession } from "./rest/multi";
import { RestSession } from "./rest/session";
export async function scenario(createSession: (s: string) => Promise<ElementSession>,
restCreator: RestSessionCreator): Promise<void> {
export async function scenario(createSession: (s: string) => Promise<ElementSession>): Promise<void> {
let firstUser = true;
async function createUser(username: string) {
const session = await createSession(username);
@ -44,15 +37,4 @@ export async function scenario(createSession: (s: string) => Promise<ElementSess
const bob = await createUser("bob");
await toastScenarios(alice, bob);
await e2eEncryptionScenarios(alice, bob);
console.log("create REST users:");
const charlies = await createRestUsers(restCreator);
await lazyLoadingScenarios(alice, bob, charlies);
}
async function createRestUsers(restCreator: RestSessionCreator): Promise<RestMultiSession> {
const usernames = range(1, 10).map((i) => `charly-${i}`);
const charlies = await restCreator.createSessionRange(usernames, "testtest", "charly-1..10");
await charlies.setDisplayName((s: RestSession) => `Charly #${s.userName().split('-')[1]}`);
return charlies;
}

View file

@ -1,53 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019, 2020 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 { strict as assert } from 'assert';
import { ElementSession } from "../session";
import { sendMessage } from '../usecases/send-message';
import { acceptInvite } from '../usecases/accept-invite';
import { receiveMessage } from '../usecases/timeline';
import { createDm } from '../usecases/create-room';
import { checkRoomSettings } from '../usecases/room-settings';
import { startSasVerification, acceptSasVerification } from '../usecases/verify';
import { setupSecureBackup } from '../usecases/security';
import { measureStart, measureStop } from '../util';
export async function e2eEncryptionScenarios(alice: ElementSession, bob: ElementSession) {
console.log(" creating an e2e encrypted DM and join through invite:");
await createDm(bob, ['@alice:localhost']);
await checkRoomSettings(bob, { encryption: true }); // for sanity, should be e2e-by-default
await acceptInvite(alice, 'bob');
// do sas verification
bob.log.step(`starts SAS verification with ${alice.username}`);
await measureStart(bob, "mx_VerifyE2EEUser");
const bobSasPromise = startSasVerification(bob, alice.username);
const aliceSasPromise = acceptSasVerification(alice, bob.username);
// wait in parallel, so they don't deadlock on each other
// the logs get a bit messy here, but that's fine enough for debugging (hopefully)
const [bobSas, aliceSas] = await Promise.all([bobSasPromise, aliceSasPromise]);
assert.deepEqual(bobSas, aliceSas);
await measureStop(bob, "mx_VerifyE2EEUser");
bob.log.done(`done (match for ${bobSas.join(", ")})`);
const aliceMessage = "Guess what I just heard?!";
await sendMessage(alice, aliceMessage);
await receiveMessage(bob, { sender: "alice", body: aliceMessage, encrypted: true });
const bobMessage = "You've got to tell me!";
await sendMessage(bob, bobMessage);
await receiveMessage(alice, { sender: "bob", body: bobMessage, encrypted: true });
await setupSecureBackup(alice);
}

View file

@ -1,127 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 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 { strict as assert } from 'assert';
import { delay } from '../util';
import { join } from '../usecases/join';
import { sendMessage } from '../usecases/send-message';
import {
checkTimelineContains,
scrollToTimelineTop,
} from '../usecases/timeline';
import { createRoom } from '../usecases/create-room';
import { getMembersInMemberlist } from '../usecases/memberlist';
import { changeRoomSettings } from '../usecases/room-settings';
import { RestMultiSession } from "../rest/multi";
import { ElementSession } from "../session";
export async function lazyLoadingScenarios(alice: ElementSession,
bob: ElementSession, charlies: RestMultiSession): Promise<void> {
console.log(" creating a room for lazy loading member scenarios:");
const charly1to5 = charlies.slice("charly-1..5", 0, 5);
const charly6to10 = charlies.slice("charly-6..10", 5);
assert(charly1to5.sessions.length == 5);
assert(charly6to10.sessions.length == 5);
await setupRoomWithBobAliceAndCharlies(alice, bob, charly1to5);
await checkPaginatedDisplayNames(alice, charly1to5);
await checkMemberList(alice, charly1to5);
await joinCharliesWhileAliceIsOffline(alice, charly6to10);
await checkMemberList(alice, charly6to10);
await charlies.room(alias).leave();
await delay(1000);
await checkMemberListLacksCharlies(alice, charlies);
await checkMemberListLacksCharlies(bob, charlies);
}
const room = "Lazy Loading Test";
const alias = "#lltest:localhost";
const charlyMsg1 = "hi bob!";
const charlyMsg2 = "how's it going??";
async function setupRoomWithBobAliceAndCharlies(alice: ElementSession, bob: ElementSession,
charlies: RestMultiSession): Promise<void> {
await createRoom(bob, room);
await changeRoomSettings(bob, { directory: true, visibility: "public", alias });
// wait for alias to be set by server after clicking "save"
// so the charlies can join it.
await bob.delay(500);
const charlyMembers = await charlies.join(alias);
await charlyMembers.talk(charlyMsg1);
await charlyMembers.talk(charlyMsg2);
bob.log.step("sends 20 messages").mute();
for (let i = 20; i >= 1; --i) {
await sendMessage(bob, `I will only say this ${i} time(s)!`);
}
bob.log.unmute().done();
await join(alice, alias);
}
async function checkPaginatedDisplayNames(alice: ElementSession, charlies: RestMultiSession): Promise<void> {
await scrollToTimelineTop(alice);
//alice should see 2 messages from every charly with
//the correct display name
const expectedMessages = [charlyMsg1, charlyMsg2].reduce((messages, msgText) => {
return charlies.sessions.reduce((messages, charly) => {
return messages.concat({
sender: charly.displayName(),
body: msgText,
});
}, messages);
}, []);
await checkTimelineContains(alice, expectedMessages, charlies.log.username);
}
async function checkMemberList(alice: ElementSession, charlies: RestMultiSession): Promise<void> {
alice.log.step(`checks the memberlist contains herself, bob and ${charlies.log.username}`);
const displayNames = (await getMembersInMemberlist(alice)).map((m) => m.displayName);
assert(displayNames.includes("alice"));
assert(displayNames.includes("bob"));
charlies.sessions.forEach((charly) => {
assert(displayNames.includes(charly.displayName()),
`${charly.displayName()} should be in the member list, ` +
`only have ${displayNames}`);
});
alice.log.done();
}
async function checkMemberListLacksCharlies(session: ElementSession, charlies: RestMultiSession): Promise<void> {
session.log.step(`checks the memberlist doesn't contain ${charlies.log.username}`);
const displayNames = (await getMembersInMemberlist(session)).map((m) => m.displayName);
charlies.sessions.forEach((charly) => {
assert(!displayNames.includes(charly.displayName()),
`${charly.displayName()} should not be in the member list, ` +
`only have ${displayNames}`);
});
session.log.done();
}
async function joinCharliesWhileAliceIsOffline(alice: ElementSession, charly6to10: RestMultiSession) {
await alice.setOffline(true);
await delay(1000);
const members6to10 = await charly6to10.join(alias);
const member6 = members6to10.rooms[0];
member6.log.step("sends 20 messages").mute();
for (let i = 20; i >= 1; --i) {
await member6.talk("where is charly?");
}
member6.log.unmute().done();
const catchupPromise = alice.waitForNextSuccessfulSync();
await alice.setOffline(false);
await catchupPromise;
await delay(2000);
}

View file

@ -118,24 +118,6 @@ export class ElementSession {
return await this.page.$$(selector);
}
/** wait for a /sync request started after this call that gets a 200 response */
public async waitForNextSuccessfulSync(): Promise<void> {
const syncUrls = [];
function onRequest(request) {
if (request.url().indexOf("/sync") !== -1) {
syncUrls.push(request.url());
}
}
this.page.on('request', onRequest);
await this.page.waitForResponse((response) => {
return syncUrls.includes(response.request().url()) && response.status() === 200;
});
this.page.off('request', onRequest);
}
public async waitNoSpinner(): Promise<void> {
await this.page.waitForSelector(".mx_Spinner", { hidden: true });
}
@ -152,13 +134,6 @@ export class ElementSession {
return delay(ms);
}
public async setOffline(enabled: boolean): Promise<void> {
const description = enabled ? "offline" : "back online";
this.log.step(`goes ${description}`);
await this.page.setOfflineMode(enabled);
this.log.done();
}
public async close(): Promise<void> {
return this.browser.close();
}

View file

@ -15,7 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { strict as assert } from 'assert';
import { ElementHandle } from "puppeteer";
import { openRoomSummaryCard } from "./rightpanel";
@ -29,46 +28,6 @@ export async function openMemberInfo(session: ElementSession, name: String): Pro
await matchingLabel.click();
}
interface Device {
id: string;
key: string;
}
export async function verifyDeviceForUser(session: ElementSession, name: string,
expectedDevice: Device): Promise<void> {
session.log.step(`verifies e2e device for ${name}`);
const membersAndNames = await getMembersInMemberlist(session);
const matchingLabel = membersAndNames.filter((m) => {
return m.displayName === name;
}).map((m) => m.label)[0];
await matchingLabel.click();
// click verify in member info
const firstVerifyButton = await session.query(".mx_MemberDeviceInfo_verify");
await firstVerifyButton.click();
// expect "Verify device" dialog and click "Begin Verification"
const dialogHeader = await session.innerText(await session.query(".mx_Dialog .mx_Dialog_title"));
assert(dialogHeader, "Verify device");
const beginVerificationButton = await session.query(".mx_Dialog .mx_Dialog_primary");
await beginVerificationButton.click();
// get emoji SAS labels
const sasLabelElements = await session.queryAll(
".mx_VerificationShowSas .mx_VerificationShowSas_emojiSas .mx_VerificationShowSas_emojiSas_label");
const sasLabels = await Promise.all(sasLabelElements.map(e => session.innerText(e)));
console.log("my sas labels", sasLabels);
const dialogCodeFields = await session.queryAll(".mx_QuestionDialog code");
assert.strictEqual(dialogCodeFields.length, 2);
const deviceId = await session.innerText(dialogCodeFields[0]);
const deviceKey = await session.innerText(dialogCodeFields[1]);
assert.strictEqual(expectedDevice.id, deviceId);
assert.strictEqual(expectedDevice.key, deviceKey);
const confirmButton = await session.query(".mx_Dialog_primary");
await confirmButton.click();
const closeMemberInfo = await session.query(".mx_MemberInfo_cancel");
await closeMemberInfo.click();
session.log.done();
}
interface MemberName {
label: ElementHandle;
displayName: string;

View file

@ -20,32 +20,6 @@ import { ElementHandle } from "puppeteer";
import { ElementSession } from "../session";
export async function scrollToTimelineTop(session: ElementSession): Promise<void> {
session.log.step(`scrolls to the top of the timeline`);
await session.page.evaluate(() => {
return Promise.resolve().then(async () => {
let timedOut = false;
let timeoutHandle = null;
// set scrollTop to 0 in a loop and check every 50ms
// if content became available (scrollTop not being 0 anymore),
// assume everything is loaded after 3s
do {
const timelineScrollView = document.querySelector(".mx_RoomView_timeline .mx_ScrollPanel");
if (timelineScrollView && timelineScrollView.scrollTop !== 0) {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
timeoutHandle = setTimeout(() => timedOut = true, 3000);
timelineScrollView.scrollTop = 0;
} else {
await new Promise((resolve) => setTimeout(resolve, 50));
}
} while (!timedOut);
});
});
session.log.done();
}
interface Message {
sender: string;
encrypted?: boolean;
@ -79,41 +53,6 @@ export async function receiveMessage(session: ElementSession, expectedMessage: M
session.log.done();
}
export async function checkTimelineContains(session: ElementSession, expectedMessages: Message[],
sendersDescription: string): Promise<void> {
session.log.step(`checks timeline contains ${expectedMessages.length} ` +
`given messages${sendersDescription ? ` from ${sendersDescription}`:""}`);
const eventTiles = await getAllEventTiles(session);
let timelineMessages: Message[] = await Promise.all(eventTiles.map((eventTile) => {
return getMessageFromEventTile(eventTile);
}));
//filter out tiles that were not messages
timelineMessages = timelineMessages.filter((m) => !!m);
timelineMessages.reduce((prevSender: string, m) => {
if (m.continuation) {
m.sender = prevSender;
return prevSender;
} else {
return m.sender;
}
}, "");
expectedMessages.forEach((expectedMessage) => {
const foundMessage = timelineMessages.find((message) => {
return message.sender === expectedMessage.sender &&
message.body === expectedMessage.body;
});
try {
assertMessage(foundMessage, expectedMessage);
} catch (err) {
console.log("timelineMessages", timelineMessages);
throw err;
}
});
session.log.done();
}
function assertMessage(foundMessage: Message, expectedMessage: Message): void {
assert(foundMessage, `message ${JSON.stringify(expectedMessage)} not found in timeline`);
assert.equal(foundMessage.body, expectedMessage.body);
@ -127,10 +66,6 @@ function getLastEventTile(session: ElementSession): Promise<ElementHandle> {
return session.query(".mx_EventTile_last");
}
function getAllEventTiles(session: ElementSession): Promise<ElementHandle[]> {
return session.queryAll(".mx_RoomView_MessageList .mx_EventTile");
}
async function getMessageFromEventTile(eventTile: ElementHandle): Promise<Message> {
const senderElement = await eventTile.$(".mx_DisambiguatedProfile_displayName");
const className: string = await (await eventTile.getProperty("className")).jsonValue();

View file

@ -20,14 +20,6 @@ import { padEnd } from "lodash";
import { ElementSession } from "./session";
export const range = function(start: number, amount: number, step = 1): Array<number> {
const r = [];
for (let i = 0; i < amount; ++i) {
r.push(start + (i * step));
}
return r;
};
export const delay = function(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
};

View file

@ -19,7 +19,6 @@ import { Command } from "commander";
import { ElementSession } from './src/session';
import { scenario } from './src/scenario';
import { RestSessionCreator } from './src/rest/creator';
const program = new Command();
@ -54,12 +53,7 @@ async function runTests() {
options['executablePath'] = path;
}
const restCreator = new RestSessionCreator(
hsUrl,
program.opts().registrationSharedSecret,
);
async function createSession(username) {
async function createSession(username: string) {
const session = await ElementSession.create(
username, options, program.opts().appUrl, hsUrl, program.opts().throttleCpu,
);
@ -69,7 +63,7 @@ async function runTests() {
let failure = false;
try {
await scenario(createSession, restCreator);
await scenario(createSession);
} catch (err) {
failure = true;
console.log('failure: ', err);

View file

@ -0,0 +1,88 @@
/*
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 { mocked } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import TypingStore from "../../src/stores/TypingStore";
import { LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom";
import SettingsStore from "../../src/settings/SettingsStore";
jest.mock("../../src/settings/SettingsStore", () => ({
getValue: jest.fn(),
}));
describe("TypingStore", () => {
let typingStore: TypingStore;
let mockClient: MatrixClient;
const settings = {
"sendTypingNotifications": true,
"feature_thread": false,
};
const roomId = "!test:example.com";
const localRoomId = LOCAL_ROOM_ID_PREFIX + "test";
beforeEach(() => {
typingStore = new TypingStore();
mockClient = {
sendTyping: jest.fn(),
} as unknown as MatrixClient;
MatrixClientPeg.get = () => mockClient;
mocked(SettingsStore.getValue).mockImplementation((setting: string) => {
return settings[setting];
});
});
describe("setSelfTyping", () => {
it("shouldn't do anything for a local room", () => {
typingStore.setSelfTyping(localRoomId, null, true);
expect(mockClient.sendTyping).not.toHaveBeenCalled();
});
describe("in typing state true", () => {
beforeEach(() => {
typingStore.setSelfTyping(roomId, null, true);
});
it("should change to false when setting false", () => {
typingStore.setSelfTyping(roomId, null, false);
expect(mockClient.sendTyping).toHaveBeenCalledWith(roomId, false, 30000);
});
it("should change to true when setting true", () => {
typingStore.setSelfTyping(roomId, null, true);
expect(mockClient.sendTyping).toHaveBeenCalledWith(roomId, true, 30000);
});
});
describe("in typing state false", () => {
beforeEach(() => {
typingStore.setSelfTyping(roomId, null, false);
});
it("shouldn't change when setting false", () => {
typingStore.setSelfTyping(roomId, null, false);
expect(mockClient.sendTyping).not.toHaveBeenCalled();
});
it("should change to true when setting true", () => {
typingStore.setSelfTyping(roomId, null, true);
expect(mockClient.sendTyping).toHaveBeenCalledWith(roomId, true, 30000);
});
});
});
});

View file

@ -205,7 +205,11 @@ export const makeRoomWithBeacons = (
const room = makeRoomWithStateEvents(beaconInfoEvents, { roomId, mockClient });
const beacons = beaconInfoEvents.map(event => room.currentState.beacons.get(getBeaconInfoIdentifier(event)));
if (locationEvents) {
beacons.forEach(beacon => beacon.addLocations(locationEvents));
beacons.forEach(beacon => {
// this filtering happens in roomState, which is bypassed here
const validLocationEvents = locationEvents?.filter(event => event.getSender() === beacon.beaconInfoOwner);
beacon.addLocations(validLocationEvents);
});
}
return beacons;
};

View file

@ -31,6 +31,7 @@ import {
IEventRelation,
IUnsigned,
} from 'matrix-js-sdk/src/matrix';
import { normalize } from "matrix-js-sdk/src/utils";
import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg';
import dis from '../../src/dispatcher/dispatcher';
@ -389,6 +390,7 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl
removeListener: jest.fn(),
getDMInviter: jest.fn(),
name,
normalizedName: normalize(name || ""),
getAvatarUrl: () => 'mxc://avatar.url/room.png',
getMxcAvatarUrl: () => 'mxc://avatar.url/room.png',
isSpaceRoom: jest.fn().mockReturnValue(false),

View file

@ -35,7 +35,8 @@ export function untilDispatch(waitForAction: DispatcherAction): Promise<ActionPa
});
}
const findByAttr = (attr: string) => (component: ReactWrapper, value: string) => component.find(`[${attr}="${value}"]`);
export const findByAttr = (attr: string) => (component: ReactWrapper, value: string) =>
component.find(`[${attr}="${value}"]`);
export const findByTestId = findByAttr('data-test-id');
export const findById = findByAttr('id');
export const findByAriaLabel = findByAttr('aria-label');

View file

@ -0,0 +1,52 @@
/*
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/matrix";
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom";
import { isLocalRoom } from "../../../src/utils/localRoom/isLocalRoom";
import { createTestClient } from "../../test-utils";
describe("isLocalRoom", () => {
let room: Room;
let localRoom: LocalRoom;
beforeEach(() => {
const client = createTestClient();
room = new Room("!room:example.com", client, client.getUserId());
localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, client.getUserId());
});
it("should return false for null", () => {
expect(isLocalRoom(null)).toBe(false);
});
it("should return false for a Room", () => {
expect(isLocalRoom(room)).toBe(false);
});
it("should return false for a non-local room ID", () => {
expect(isLocalRoom(room.roomId)).toBe(false);
});
it("should return true for LocalRoom", () => {
expect(isLocalRoom(localRoom)).toBe(true);
});
it("should return true for local room ID", () => {
expect(isLocalRoom(LOCAL_ROOM_ID_PREFIX + "test")).toBe(true);
});
});

View file

@ -2853,10 +2853,12 @@ balanced-match@^2.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9"
integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==
base-x@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/base-x/-/base-x-4.0.0.tgz#d0e3b7753450c73f8ad2389b5c018a4af7b2224a"
integrity sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==
base-x@^3.0.2:
version "3.0.9"
resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.9.tgz#6349aaabb58526332de9f60995e548a53fe21320"
integrity sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==
dependencies:
safe-buffer "^5.0.1"
base64-js@^1.3.1:
version "1.5.1"
@ -2974,12 +2976,12 @@ browserslist@^4.20.2, browserslist@^4.21.1:
node-releases "^2.0.5"
update-browserslist-db "^1.0.4"
bs58@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/bs58/-/bs58-5.0.0.tgz#865575b4d13c09ea2a84622df6c8cbeb54ffc279"
integrity sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==
bs58@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a"
integrity sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==
dependencies:
base-x "^4.0.0"
base-x "^3.0.2"
bser@2.1.1:
version "2.1.1"
@ -3539,7 +3541,7 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2"
integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==
cypress-real-events@^1.7.0:
cypress-real-events@^1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.7.1.tgz#8f430d67c29ea4f05b9c5b0311780120cbc9b935"
integrity sha512-/Bg15RgJ0SYsuXc6lPqH08x19z6j2vmhWN4wXfJqm3z8BTAFiK2MvipZPzxT8Z0jJP0q7kuniWrLIvz/i/8lCQ==
@ -7284,7 +7286,7 @@ p-map@^4.0.0:
dependencies:
aggregate-error "^3.0.0"
p-retry@4:
p-retry@^4.5.0:
version "4.6.2"
resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16"
integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==