From ebef0d353e22f64e903d2209fb075a550c29cc2d Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Wed, 8 Jan 2025 22:45:06 +0530 Subject: [PATCH] Implement new memberlist design with MVVM architecture (#28874) * Add new e2e icon for the member tile * Add new presence icon for member tile * Implement new member tile * Implement memberlist view model * Implement new memberlist header view * Support the new memberlist in Diasambiguated profile 1. Use MemberInfo instead of RoomMember 2. CSS changes to reflect the new design * Implement new memberlist view * Add and use a new overflow component We used the EntityTile component as a pretend overflow tile in some places. This new lighter component is added so that we can remove the complex EntityTile component. * Remove old code * Add/remove css files from _components.pcss * Increase minimum width as per design * Actually use the new memberlist view * Fix broken jest tests * Add jest tests * Playwright: Make it possible to disable presence * Add playwright tests * Fix lint error * Undo translation changes that must be done via localazy * Update license header * Use waitFor instead of setTimeout * Remove comment * Switch over from template to container hs * Revert unintended change * Move config to top level --- package.json | 6 +- playwright/e2e/crypto/dehydration.spec.ts | 4 +- .../e2e/lazy-loading/lazy-loading.spec.ts | 2 +- playwright/e2e/right-panel/memberlist.spec.ts | 48 ++ .../e2e/right-panel/right-panel.spec.ts | 10 +- playwright/element-web-test.ts | 9 +- playwright/pages/ElementAppPage.ts | 12 + .../with-four-members-linux.png | Bin 0 -> 17967 bytes playwright/testcontainers/synapse.ts | 4 + res/css/_components.pcss | 8 +- .../views/messages/_DisambiguatedProfile.pcss | 23 +- res/css/views/rooms/_E2EIconView.pcss | 20 + res/css/views/rooms/_EntityTile.pcss | 128 ----- res/css/views/rooms/_MemberList.pcss | 69 --- .../views/rooms/_MemberListHeaderView.pcss | 37 ++ res/css/views/rooms/_MemberListView.pcss | 17 + res/css/views/rooms/_MemberTileView.pcss | 58 +++ res/css/views/rooms/_OverflowTile.pcss | 51 ++ res/css/views/rooms/_PresenceIconView.pcss | 32 ++ src/components/structures/MainSplit.tsx | 2 +- src/components/structures/RightPanel.tsx | 21 +- .../memberlist/MemberListViewModel.tsx | 263 ++++++++++ .../memberlist/tiles/MemberTileViewModel.tsx | 160 +++++++ .../tiles/ThreePidTileViewModel.tsx | 35 ++ .../views/dialogs/ForwardDialog.tsx | 17 +- .../views/messages/DisambiguatedProfile.tsx | 10 +- src/components/views/rooms/E2EIcon.tsx | 2 +- src/components/views/rooms/EntityTile.tsx | 170 ------- src/components/views/rooms/MemberList.tsx | 450 ----------------- .../rooms/MemberList/MemberListHeaderView.tsx | 137 ++++++ .../views/rooms/MemberList/MemberListView.tsx | 82 ++++ .../MemberList/tiles/RoomMemberTileView.tsx | 67 +++ .../tiles/ThreePidInviteTileView.tsx | 23 + .../MemberList/tiles/common/E2EIconView.tsx | 47 ++ .../tiles/common/MemberTileLayout.tsx | 40 ++ .../tiles/common/PresenceIconView.tsx | 44 ++ src/components/views/rooms/MemberTile.tsx | 220 --------- .../views/rooms/OverflowTileView.tsx | 32 ++ src/i18n/strings/en_EN.json | 9 +- src/models/rooms/PresenceState.ts | 8 + src/models/rooms/RoomMember.ts | 22 + src/models/rooms/ThreePIDInvite.ts | 12 + test/test-utils/test-utils.ts | 2 + .../components/structures/RightPanel-test.tsx | 4 +- .../__snapshots__/MainSplit-test.tsx.snap | 4 +- .../__snapshots__/RoomView-test.tsx.snap | 2 +- .../views/rooms/MemberList-test.tsx | 452 ------------------ .../views/rooms/MemberTile-test.tsx | 73 --- .../__snapshots__/MemberTile-test.tsx.snap | 160 ------- .../memberlist/MemberListHeaderView-test.tsx | 120 +++++ .../rooms/memberlist/MemberListView-test.tsx | 255 ++++++++++ .../rooms/memberlist/MemberTileView-test.tsx | 116 +++++ .../memberlist/PresenceIconView-test.tsx | 42 ++ .../MemberTileView-test.tsx.snap | 231 +++++++++ .../PresenceIconView-test.tsx.snap | 175 +++++++ .../views/rooms/memberlist/common.tsx | 146 ++++++ yarn.lock | 51 +- 57 files changed, 2456 insertions(+), 1788 deletions(-) create mode 100644 playwright/e2e/right-panel/memberlist.spec.ts create mode 100644 playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png create mode 100644 res/css/views/rooms/_E2EIconView.pcss delete mode 100644 res/css/views/rooms/_EntityTile.pcss delete mode 100644 res/css/views/rooms/_MemberList.pcss create mode 100644 res/css/views/rooms/_MemberListHeaderView.pcss create mode 100644 res/css/views/rooms/_MemberListView.pcss create mode 100644 res/css/views/rooms/_MemberTileView.pcss create mode 100644 res/css/views/rooms/_OverflowTile.pcss create mode 100644 res/css/views/rooms/_PresenceIconView.pcss create mode 100644 src/components/viewmodels/memberlist/MemberListViewModel.tsx create mode 100644 src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx create mode 100644 src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx delete mode 100644 src/components/views/rooms/EntityTile.tsx delete mode 100644 src/components/views/rooms/MemberList.tsx create mode 100644 src/components/views/rooms/MemberList/MemberListHeaderView.tsx create mode 100644 src/components/views/rooms/MemberList/MemberListView.tsx create mode 100644 src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx create mode 100644 src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx create mode 100644 src/components/views/rooms/MemberList/tiles/common/E2EIconView.tsx create mode 100644 src/components/views/rooms/MemberList/tiles/common/MemberTileLayout.tsx create mode 100644 src/components/views/rooms/MemberList/tiles/common/PresenceIconView.tsx delete mode 100644 src/components/views/rooms/MemberTile.tsx create mode 100644 src/components/views/rooms/OverflowTileView.tsx create mode 100644 src/models/rooms/PresenceState.ts create mode 100644 src/models/rooms/RoomMember.ts create mode 100644 src/models/rooms/ThreePIDInvite.ts delete mode 100644 test/unit-tests/components/views/rooms/MemberList-test.tsx delete mode 100644 test/unit-tests/components/views/rooms/MemberTile-test.tsx delete mode 100644 test/unit-tests/components/views/rooms/__snapshots__/MemberTile-test.tsx.snap create mode 100644 test/unit-tests/components/views/rooms/memberlist/MemberListHeaderView-test.tsx create mode 100644 test/unit-tests/components/views/rooms/memberlist/MemberListView-test.tsx create mode 100644 test/unit-tests/components/views/rooms/memberlist/MemberTileView-test.tsx create mode 100644 test/unit-tests/components/views/rooms/memberlist/PresenceIconView-test.tsx create mode 100644 test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap create mode 100644 test/unit-tests/components/views/rooms/memberlist/__snapshots__/PresenceIconView-test.tsx.snap create mode 100644 test/unit-tests/components/views/rooms/memberlist/common.tsx diff --git a/package.json b/package.json index 3886b129818..7cda6fa5ace 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^8.0.0", "@types/png-chunks-extract": "^1.0.2", - "@vector-im/compound-design-tokens": "^2.0.1", + "@vector-im/compound-design-tokens": "^2.1.0", "@vector-im/compound-web": "^7.5.0", "@vector-im/matrix-wysiwyg": "2.38.0", "@zxcvbn-ts/core": "^3.0.4", @@ -151,7 +151,9 @@ "temporal-polyfill": "^0.2.5", "ua-parser-js": "^1.0.2", "uuid": "^11.0.0", - "what-input": "^5.2.10" + "what-input": "^5.2.10", + "@types/react-virtualized": "^9.21.30", + "react-virtualized": "^9.22.5" }, "devDependencies": { "@action-validator/cli": "^0.6.0", diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts index 7c1bbd7ac48..6d7b6c0c7e9 100644 --- a/playwright/e2e/crypto/dehydration.spec.ts +++ b/playwright/e2e/crypto/dehydration.spec.ts @@ -16,7 +16,7 @@ const ROOM_NAME = "Test room"; const NAME = "Alice"; function getMemberTileByName(page: Page, name: string): Locator { - return page.locator(`.mx_EntityTile, [title="${name}"]`); + return page.locator(`.mx_MemberTileView, [title="${name}"]`); } test.use({ @@ -88,7 +88,7 @@ test.describe("Dehydration", () => { await viewRoomSummaryByName(page, app, ROOM_NAME); await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click(); - await expect(page.locator(".mx_MemberList")).toBeVisible(); + await expect(page.locator(".mx_MemberListView")).toBeVisible(); await getMemberTileByName(page, NAME).click(); await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click(); diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts index 6c9457dfafb..ace6fdb7380 100644 --- a/playwright/e2e/lazy-loading/lazy-loading.spec.ts +++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -78,7 +78,7 @@ test.describe("Lazy Loading", () => { } function getMemberInMemberlist(page: Page, name: string): Locator { - return page.locator(".mx_MemberList .mx_EntityTile_name").filter({ hasText: name }); + return page.locator(".mx_MemberListView .mx_MemberTileView_name").filter({ hasText: name }); } async function checkMemberList(page: Page, charlies: Bot[]) { diff --git a/playwright/e2e/right-panel/memberlist.spec.ts b/playwright/e2e/right-panel/memberlist.spec.ts new file mode 100644 index 00000000000..1275e243b6a --- /dev/null +++ b/playwright/e2e/right-panel/memberlist.spec.ts @@ -0,0 +1,48 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; +import { Bot } from "../../pages/bot"; + +const ROOM_NAME = "Test room"; +const NAME = "Alice"; + +test.use({ + synapseConfigOptions: { + presence: { + enabled: false, + include_offline_users_on_sync: false, + }, + }, + displayName: NAME, + disablePresence: true, +}); + +test.describe("Memberlist", () => { + test.beforeEach(async ({ app, user, page, homeserver }, testInfo) => { + testInfo.setTimeout(testInfo.timeout + 30_000); + const id = await app.client.createRoom({ name: ROOM_NAME }); + const newBots: Bot[] = []; + const names = ["Bob", "Bob", "Susan"]; + for (let i = 0; i < 3; i++) { + const displayName = names[i]; + const autoAcceptInvites = displayName !== "Susan"; + const bot = new Bot(page, homeserver, { displayName, startClient: true, autoAcceptInvites }); + await bot.prepareClient(); + await app.client.inviteUser(id, bot.credentials?.userId); + newBots.push(bot); + } + }); + + test("Renders correctly", { tag: "@screenshot" }, async ({ page, app }) => { + await app.viewRoomByName(ROOM_NAME); + const memberlist = await app.toggleMemberlistPanel(); + await expect(memberlist.locator(".mx_MemberTileView")).toHaveCount(4); + await expect(memberlist.getByText("(Invited)")).toHaveCount(1); + await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png"); + }); +}); diff --git a/playwright/e2e/right-panel/right-panel.spec.ts b/playwright/e2e/right-panel/right-panel.spec.ts index 2f51f92587e..cb2a11ac002 100644 --- a/playwright/e2e/right-panel/right-panel.spec.ts +++ b/playwright/e2e/right-panel/right-panel.spec.ts @@ -24,7 +24,7 @@ const ROOM_ADDRESS_LONG = "loremIpsumDolorSitAmetConsecteturAdipisicingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliqua"; function getMemberTileByName(page: Page, name: string): Locator { - return page.locator(`.mx_EntityTile, [title="${name}"]`); + return page.locator(`.mx_MemberTileView, [title="${name}"]`); } test.describe("RightPanel", () => { @@ -107,14 +107,14 @@ test.describe("RightPanel", () => { await viewRoomSummaryByName(page, app, ROOM_NAME); await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click(); - await expect(page.locator(".mx_MemberList")).toBeVisible(); + await expect(page.locator(".mx_MemberListView")).toBeVisible(); await getMemberTileByName(page, NAME).click(); await expect(page.locator(".mx_UserInfo")).toBeVisible(); await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible(); await page.getByTestId("base-card-back-button").click(); - await expect(page.locator(".mx_MemberList")).toBeVisible(); + await expect(page.locator(".mx_MemberListView")).toBeVisible(); await page.getByLabel("Room info").nth(1).click(); await checkRoomSummaryCard(page, ROOM_NAME); @@ -130,14 +130,14 @@ test.describe("RightPanel", () => { .locator(".mx_RoomInfoLine_private") .getByRole("button", { name: /\d member/ }) .click(); - await expect(page.locator(".mx_MemberList")).toBeVisible(); + await expect(page.locator(".mx_MemberListView")).toBeVisible(); await getMemberTileByName(page, NAME).click(); await expect(page.locator(".mx_UserInfo")).toBeVisible(); await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible(); await page.getByTestId("base-card-back-button").click(); - await expect(page.locator(".mx_MemberList")).toBeVisible(); + await expect(page.locator(".mx_MemberListView")).toBeVisible(); }); }); }); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 0246bed537b..0c6392fdc21 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -99,6 +99,7 @@ export interface Fixtures { bot: Bot; labsFlags: string[]; webserver: Webserver; + disablePresence: boolean; } export const test = base.extend({ @@ -110,8 +111,9 @@ export const test = base.extend({ ); await use(context); }, + disablePresence: false, config: {}, // We merge this atop the default CONFIG_JSON in the page fixture to make extending it easier - page: async ({ homeserver, context, page, config, labsFlags }, use) => { + page: async ({ homeserver, context, page, config, labsFlags, disablePresence }, use) => { await context.route(`http://localhost:8080/config.json*`, async (route) => { const json = { ...CONFIG_JSON, @@ -131,6 +133,11 @@ export const test = base.extend({ return obj; }, {}), }; + if (disablePresence) { + json["enable_presence_by_hs_url"] = { + [homeserver.baseUrl]: false, + }; + } await route.fulfill({ json }); }); await use(page); diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index a02afd27bad..98d0bf30fba 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -177,6 +177,18 @@ export class ElementAppPage { return this.page.locator(".mx_RightPanel"); } + /** + * Opens/closes the memberlist panel + * @returns locator to the memberlist panel + */ + public async toggleMemberlistPanel(): Promise { + const locator = this.page.locator(".mx_FacePile"); + await locator.click(); + const memberlist = this.page.locator(".mx_MemberListView"); + await memberlist.waitFor(); + return memberlist; + } + /** * Get a locator for the tooltip associated with an element * @param e The element with the tooltip diff --git a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..f0e14ee55cf0c7489e8bb91dfeb41b2880f323dd GIT binary patch literal 17967 zcmeIacT`hvlr|bgYzPPlNLLUL5K!q|C4kgO?@c-gp|=1*k={g_^d=Cx)DWtQbm=AZ zK6kV#!h-(82EL~O+(=gsJx$X9Rzv=QUt%&@=o8H_0rR{x$4~GSA2Nq z{_)qxnh%@982*XjHZ`LfVQMrfjLF+3`#sjs`cJ6ugpj`$iVNOZ^dIw+fCl;kJ(|C=_~mU&aZo539fSCj8c1N?lf9WFQ{*gdk=O%JTF;;5u{pxi?a5$e^zzjNOH%JLEuV2L zEL9Jmlc3pXB5_5=J55}(Ia)jL7jjb&OWm1{(#>Ac18s68zjg3MaR~6N25iP#6!tgM z{m|kji4}#(94opSoXO}GA+muEjEUJU8u8@hm58;k7A)USxv%(gn@QaLcx9wX!`Q;m zrh=KM@qGH}aZx0eHI^wN3euB^OP%&P3%<9tVW$XAsCQf_NbA2W6qlLu5`JO)-fUyY z)z+4rWkE(*>x>&WSmZ`-Vx@dfWi#WV@p%29VzZoRRE@i2@116+C&B0Rl;0$uQOc*l zbfBD(Oo1bwNz8|XU1+xZSGilL@K`x}e79nItFp!X@hMmUFzr-3Gf}5S`U)&P+P;cy%PUFw35ujJDW#`gcTK zS$-3_k{fOC=%i$aI&uiSZ!0h+2lG89>Ie?0x&J+5s>*JaZL_?sac_WWk(-gM5a^d0 znPLu~Wcs^K^4T(M8&^eZVkcOtyW0!%!?;Wh&u zN=C9W%(LB}!n!aiErjjS$+f?p*TOr?E%(yyWr@|H?oiYoR(&a-abDaUW6pd`0|x(r z3c9~1XEA1V_4wXI+?X3XOP(^hzq78moh5bj75)D2k_+!CcnMSbajEf0cR-qcbxfm= z<=QKvo>*P3>(VqX+j1H`I}-Th$X;W5@k2tkvg$fz9Q~?lks)-b%p=mNnb7ew=jcn3 zaani-W&FY0ROCjj#|u{+V!Afm+k_-X7Q29$zL&W78esZ}lQo z`6q|8`lG|;_FoQZ?O3@R8!t<`r!naY+!VNAuk7&&QAV`^rwruggdravdSq;ig7W*5 zS%WJvL{h`izR@j}%_T%hiLzu)b4JE1^~{8@uKLM|SXraFnV{7)_5Di&VNM?l1$~3r z5wANG`X1>oB`2`auf6?*R>^ny<9nAmuNvO*#jUMxBu4hK^~-a^H?@NrEbeScHcR?T z8c9i|n-nU8tz*dKpY-r78%|H^{d3SI=X0)7K$005nS2rsMF~e##)>mRx*AokmGEXK zCBMy$^D36k0;+-WNn9W4l&{`i5#pVtj#w3G>vOS^`#a8`y}$|z3f;}FsEf630sgNV z>5Q8kQH<{~KaWp)m-09Vp8D67e)wtclFVmZjOAt@C^v$!df@Ia*&WB8KifLQmSgID zWwc8NczWMTdYN7QLL|>v6QqSnpY5}lM4|A{L*3cMT#{o#X1j#<7+yAH@)v&b2s8pgFP~G$q`4)IS_9f8(5N@Z6*RpCitQxT02cbpYl9`2#%1XZ0u1dbX@&$#B4z)|sv$lUl7+dirPq*5jcQsc{*bC*2QA1)sQfZ^> zi(4J8{JlfXuyw&{hW_fE>^>r9G00}r;(pNI@buRRTO7$joTdwAo zi;GVP*)0(UYNKx4EWR1F8V$nDyP%B73a4o8n&}&1LgfX33!g4iHml8Z5oHu&hV3!4 zfrQpK9JW+sh-yQ8S&q}M>1i8j6dX6Yl{kwsTF4T4=!`nIfsW|1Dhf{@h2Y3O`Cc3C zTz0gF3bJRnTy}p;osP=`>u*UC@@E|gOw#n$61U~O3D~Ga^hauez3K3>RG;M zjEzY(iI%F!e#*-xA;38>JTp2qtP$8-(}l&~FbmY;(3EsfRL)P)_nAbU3hS(R zj?)xybX*P7j8!0i)IZs?DmLQta?6=mhBibj2bQBc! z9+fh2kESK0b}y_6f*U|M`!zEKt+@20{KO%g0aV?^&g3;k=Eil~u8}fxC!kM?MJuvn zwR4}7xsL0SQlaD^`UyRb7L_r-bn&q?-FHutd-t&S19kLL)0FauFMhu#?JUx4E~1;{ zBW19yXZIdCSmkfgtM~pp5$=G#)Zn0LO;fZq!5|Ojw?27Q@A>xEng`WHeIUJ6)NGQX zl9?E9!|`WQ%fM}8d~3d3%JTxXF>^{HQ>Hgtab+fSy?;jLcVBne$gqSxWN`|YlI8!x zi*Tl39}BV<5TGcqS?*e<&nPs4H3RFAv{I1zfoN`jOawzUG}Ly!GZVo~SQwm`P%t-# zm2CI*^*OF%jwdRqu8sw#0vbHWzOp1kc3D{?E-TaG)20zY-cJZwm4YID{e5W5&r=(` z&-g0qo}(=pc9RrhnQwX8Vyg195)+rP{H>32IU(5MZ2&Fyb#+C-&o)i7LOs%QsBWIx zoo-4Yco>uqeq$+BZVwD=6ZEgNZ5HJ7tVf_FHu_ zp7)1p9niYv2Cxe-I77av84(?Cn3fMWHk2vX?B#HhzUD7I&1Cor+zlu5aC5%<4Zd}h z=`(qBx;4WnDSwfoI53X)baQ2>5-54*mu?t~VWtSrTk(~sJrVpGhS;3^#ZCo7?%83- zsw|M|k;m;cgtXSLXK!_rb@dMU(S{~`Ln9+&|BSOpQ^uEom3uWE!*sn-CH=7Sovoz( zk~8a6i^VEVXaiWC$gpw+j9#<#?2HcyPWr=mC4aI}HH)}8_>1g929c?n9<#PQ8Oyyp zgN2)2yAoDlu$JelndQqn5~R5E4%zaLI}`aqX=_w*hG=5U0259=p$7K;3e{>#drq1u z=IBgvsbHQ(%79$PU>Fd(hez)1%l}N{~Fe&Z~jddsiJ{ZN&P4C6?{C#^(6Xp=wmPwW z`aD#aM>fahFf#tikMrXFOFBH$@Yv_IodbVHiRje(m#ZH;?~oZQ#4uXE!8(wP%hMPf z&o;rdIUiF*ALeB%a@Ji0_+zVtulg&#OAn8X@LnM|g5hoRuZgZp)W8YY;FuV-wva@d zA1Ns*wGPuOlhrp^?bntj%FFDF)*5|KTrI9ONLJQLJryvAv4W?7wEsMINCI2eg2rDa zs*({Ycu;aPafuzP3sVF`?5PMVC*54cVDF);OLbUt1pKkLpXAZl^>DE3*5u< z)(1ybRn_4U0S(O&GG_5A|4uawO`nLm|wA0^s1`*;}j6^hBfy)LZ*Tih1?s)mcBCL8v^Ezn*Poc$|8cNTUY!Z-o$3X1pTULX5vpJa&c2?$Ce0Nk4i~C0EmoF#K53|h!k7WE=haDuU zkQTMddgKN_z9%OK{3et!c+GXQ;#~}n38KG}%eT(#)^UpirQ*C3l`x+#`!>DVseel) zNjJ(ar|4gzE{2a9UvFIqPTm7!>cwn#Lu>i0EiC*uR%)-hqvn|!otJeAJp&k-m}(Ue z{sSq!3ln-NtJDt`e#fQWw%uyoJcq_)g?&+U@S}fkXZOOYXX$FIX@0h=m%paAZt%%$ zg9)0uHT&s;wta6wX6lDKpyEF>aO}dS8LQy162-#V8qSF!gzLA+f(edxtK zK^5I#2Zws$8uZ0h80Zz)j&8D?bW4b(u>6Rg;qG6R=vr?v$-uL9ARN(HbI6DI^@VCI zq<$sy90ybL$BzhMxL;i!N^(WF4OJqw7Y;qxwOZ)UBfkp{KeoA5W~@%1o#`EyP>`25 zGWgm7t)+8HbvJ`)>(6^dqu+FyEk zs@x7pB9-q(D*agoKti|*AG+fZ^zGNO^WmZU(UE&_M+jgr%5IvZkk6%wo;c#bE2+d- zF`}Qa>qpmr-2LCwA_{*sa`lZ3UiV6rMP1F)xvU=+xgapmAk=*-NbSW z#~U5%-~8&=NSARapUmHB?wX+B)ri@W zq{`wG@^qB3v^PcfKpxRHM@-!J#Mx1#GD1c8L1ehP;veFP zuN~x#o)dS_wFfv(`kJ9)e;ZoOKEZ$Eq}&2e>DJpd(a(v1*y`$13BgG~J70Zco8?6C z%rSAo^fXhyDpxLuRa1TT_qR=A;_s{XH3f6d$1WXl?I$U|?X72><;|VMLW6zvRX#BH2Gzmu zbn%_1d)R(v>7zFqeVf$CUnNlp9g3d@8A=;7>O>}O*7 z9^^mxnvH#I-Y+d%x{@+Y7iN4xr0DmLcogzscy8Z*#L8MY-@@bo`}Il)5s*!Z6>@zrXGy1`~caO&T&A`+q@q^KZ{wzK;wXiVsQ;>xwt&n+xE< z$z_>?!IA^gdGd8jR21xy-@%mOVeWlR=W2!$Z=+2?KVh$rd`Wh;#1G$M=U`@3+)tlz zCr~Oz+$E7qdegE!ZNcD!#A9V*T2D6<7Z;yZ?aU2@sA2~cOo{*PAXt;1?=-#va}QpT2Y1ZM(9I6j+q)1^<<>W-<~(<~t* zReS2PRC!Ah?zVowW0UuCGsIuGq5aAfvTu9!DH`d1=}YbiWv5nT2fuM6(uUsw?GTOO z(}PLk;M9Rhva1NJSAU&N>tjlRA!8>&0 zZHmb_Ur_hUY|XsChzxbPhL!SPIc>7f!n!8ln@hvZoe;UrSc{+X`rwiIO87x8|ywPqTkjK1=B1k944WOOPGoDx(#?;SU;%rP4j=QX{;W=62C-PRnlh8il05@fxOa6KnOQ#m z;b1vd+=}#~^(yBhbb|)3mo+Qr2(~_Zf0Q3NZSjY_M_TdkPdypu1k+du5pluEk~ApV zHpj#c^~|+5I=+N2ktSixh5BRo>wy7@o2t93>n=hw;;)+X?V7FRu%vnT4Fj)uNQ zSd1YC^Ha|>{tjr=kbW#}@0rbImlWL{Tx%<)V-~x})b>H@*%^Y4qkcNxedPRUYD*D}+fWW^ea?YO=Vmm*6+(#1iDnZW<{-mGibl3fxdEmz5}JLsSo2z z%M_o?ycMq|6xYcUAvi~ikWU*0-yd0R^)HU5_R8tAPcc!=x2k4T3;j+uzMq`HqyI`F zbSiq+QJ^NJIXRly6!|D`P2KW$QLV14$uhb|N#DiiWWQ@Lu{i;Hb`l<@ng)j-uDwX^ zbsWk)q-16ur6Teg8 zXbmeQ_IzY<($74a|IyY|v(4N^a0_p$*Uns7?x$lG(UXhQ$XN-b*V>?3KFc*+*$6!2 z#Y@2t7(&M7uT8S)jg9GCMGHt-RKb3x+syK6PwaEzdrrk~MUE85v#Mjt$YOxFC+yGLRAw=&jIBn?~;3AIgGn`%=>C;ypSc7q*MgwD6}A600Q@ zt=j(EW16VB3AZ~vw9e4Q?7km2UFwIc~?{u z@Kaj4AH}Ch72k-2mKrujgwaPB3D5pB_d1(+Yt=O5*+M6EJ?;qp zvpc>pJ)O}|5Lz&YqoG+jSnWUFb>_^K@!F1wjMTOGLeUm#-0UkQB&6Le)+D#=8_!(8 zW19W6aeqGEG>b!AJW(WsKlVc0xed)EqSW)M@aT4xx6sAxabWD3XK`HGTfDJyzfz|ys8GBt#=)p($DHk5!*Ph=Di@Ax+$M7 zt_p4~$4nrv=8LTYj9V))fsc*&@YRzCRc@~0x8S>Ld+~N7Hl&P_Dtq4~U4}cO=|zs9 zvjKfmStW#LB94USe7GTAeonddtPW=iS5_urVc9`MPKm9RxHbo;wD-`~*DTgyKd)wH zd0j00vax`#qmSEGLRS3xZe=|W*xWG`7It?vs`WL-1Oyq>Iw3u#j}1DbV&O{QoSZfy zqG?E$@bdemNnipfhX6(x!y<9fJ5T~UJ~6{}vYejQ)qAh+VC>$$O%|(`*_skML1&?z zMbjf``NymXyV8*{EkkvVY4AB_3Kw6?wcT?sX#a)fSt(>SA-rAoCV7L`4&i_{arKH> z(%teTRJJ6k)p@%{H@>5lP_;7nv6-5h+M*LuRaMvEwsDpd1jDn4)&#V1v^&ifk8KD; zr*?MU^!-?|2-wDtbz?{y>^GEp3>hp;|3UR{{Qi`}%H?(bI^#ZURDql9JNl z;bGWYU0q#{M8e5PF&7JDXQ;&VFgor19_H4t6ob#xmgg^DQr-=2XZIG0bkryk1YWc5 zp}j1x+GFVQ4kjWjgp}zSWjH|UOUud{-0z5c@7|`6^4@(eCYBG`!I4kSz;|~~4`?y| z)uV%hgF{0LBl=m4(yj?%fd3FRztEGx(A>OvQ$Qxg$;QU8R!Nq#Gawgo`Q$Rr%_otbo^|KPESp0mn2_kSFx%OIjdRk6lnm!x0|WR2h>a)N z8T?#JTwCIDH|tB5=_|0cb-`6r@8~Fak2tw^dC!}&=yO;IIfL)oE=4;Lw6&#~l$5lD zNmxjz7J=w$EHb6qp;2uz{Cno^X<$H4VP$QJ5)~;m_}e~-!a2B+M5Y_@rhiAMHNSCS zTPB&P*Pp02yzLmJv=2tMu>|)?p%BXr5}hnm>eWusjD8kuzI^dMu>SkyagfeM&BcEEK|d)&$kwC+`23h@ z*Kx@2oW+m>l6s$Vn4OJHHqn&mc}Ev;lYNrAxO%^2zSpdAYvX0p3!O2osObwDLqkJN z&5=+%R4S(VT=dv!zvQz5`3_}VbhHQdpw;x1&%s7hlVlz}f%jcvkB!6avS`=%#LK(V zuP!Oo_(w704kpr;+~pz&QpyE$!dBs}gu~`d=FBczUX<52f8oIo_7|5zq$B`umOM{`}S2(Be`;PxB+2 z)ev+Z^aG}Hq@&|(qB4E}u<5-TzkCXB_``<}C#FTbj|G3B%-h7DsYxU~Q zo0^3`%p@cx8rM1*88vlotXk1)oK~WTI(1I3SFUDJ#A&)ULdavC&dm>Kq!`Ez=!gBS zZ?S$2``%sI!;DQJZ?Rt{cU$#KN=d8*d}5zroq>Tqp>7ZTE9e%bdRvGZ@J^17pwG9@ zmudM;OioAki)*dn(hkR~)iF#GFPtuxfY`@CC+>Gz1r4|D!%_c>fVOhZPs8UlF<7ZP zuw6{x4&-H;LQ254{brM|oF~4Npv%cwboz*PqSiH$j?+Ugo&w1w8({sbs@(w3$1?Fk z?-@-jGqnL~k;?hd+zle{7^z;h9VNxq6f~=yO8$yT4v$q&@1>ju@Cxll12`RgEbe*piTW+h}q90$HS40r<(qZ#q*M3cE|f z9XeBDQDpBR2%kE6%<1c6lkut4&&tZ@b8d6e*Qh}Wn$;#m7MJVhY${t(LRlXZBPVN& z+dNLa%cx%o;j*~w#$Sm_efaz_E|VioL~wOrg38|V@u21Xz%#{>lFH8IgwU>4W^WrU+w?T8c!H_paUv!OT{qbG+)r|msF$?9_t;EJtv4>*|^L-$Y{qR)b zmqhAhh0=xxs3ZBZtT*F!A4-_Bi0$ipNkIlBy05vDq#{(p#3<8fuFj}l@tDA`BMP;9 z15BiUF`wLc2PzwMZ3gYi1cko-&jI$myAev5BSuiRFGoku+*!;4ke&*bviZ+__#aka zcN%~G{k{HXunjUsS}79dFXWkl*0q5CPke3^&Gd?==9n*d_@XLoESb+Kw&*DS-F1c2 zqZ7e*X7<^MTh~Vo4G_8pe33J*i%Z4I`NUQpMsjBF2M-Qn05|XBYeb49dk3{Mfa=G2 zx2-k1B%2~Coes7$7sv4;&&Sc0*Fi@~@<-N^z4`#imVXm7#Rhm*zb1=sWuf^~YyfV@ zvAp8$=eiUUREc-zIcKRYe9C~vgWjd29`!ugKVrjMa;(@(2mSp#6K!$a^Tf3;^g2R! zv=smMtR(zJ_f#tQcl39GjS71Q!9d?P8NQKK%4|BOt7rha^ZOIDbRvRxNaUTbxKC2YA52u_Jub z+1a(x>*^YX|I34F;Vp5R2M@-rUV-F`j4BZC{Dd~u$|7smCiT$S`(Qo>FOtF)&)RZV z#{pyYoci&0K9h#@_;?FDJD1(TxYQ&UXJ=;*4E<{=33EHZ)uSldJF_ma%dw6L!% z)^FQivf4`x7LIFwU#_a}9dIz-?22>F8T20iOE-|!S`XU^sO|lmaWJ`^F@5S6P?mSC zNs&8Ic+ZIfa2(w)fynmMbI<+zzTP?nm^$E7orfqfN!OS&rTx!I z#bL*OWA9h_!N=PIKWE!YR2ySV;RpWa=IUFTpRa1%R{6cX>vgrXX6h;OPHivGkLY9q zqJ&qpr4lz(eZQG(ybSPfdH|IRYu#r(_Pb7zGDR<9zf*cTZ_}U7!^L%)ZKx|D(P-V5 z%1kQ5m#CEZj6c3xK8Cr~0iXFIPl52`LZao>bEn@+BysBC2OfLF-zup9;T&CR@*-g7 z8xwq+Ucia9T-9`7YqGJ`tAbey=1D%?Sb>v|dn;(O{i(yGFRgYLesDT9|F})JLSMvP z&vs;`jK&{zc)TUi?2Cs^HI039p^A)%2z!bhHH95-vgIb0jQ5{?=>MyqCE~EaGAR5U zJ8D<OKqQ`Ct|{EGG%Y~0*y9clnLaSrtfyqrZ#Cp4%~5XUESP>Bx*Qw zFK>}?=`YVU-vU3N)HoHChd_3KPEX&c0m_oRw&#>p{I=)1rn@KGx+g96zE*^qKw?99ilUL1UgRf{OfctIi6nt`1z zd0~pc)Rc5)P(enSPydA9W{n6(jgB0nwe_^u?M(RK4oxWv3G|Y-uWCyK9dNSX)d5r6 z;R#I_uuJza^iz<8*E-|U;-ayU(dNQ-|FUcNQ{iKEpt55O{saMa!nDYOY1szo>F6lb z)tsErPEw0l?Ck99y?gg4sj{Z0MIPmjj%robC&e=nY-)@u^oepxS}j!Z!dp0Vvr3V8Bjr^9`Fzm-jtY-sGL7Id{gkQID5SqkX@DE-Bs*pPe730YaK)_ntD_ZPSis#Jkjby6@qertMqY)o#ithQDg zUH%Jpylq(JJ@oV1Wy`74mA*8Nnf9 z46S`7ab+k-u};712NO|;?(Xg`0$k1C7w>x@PeKw&wl!_s;wQw$Ry|c)Z?s)5gN%MC z{b8C#V&!j2R$@l8&&b(4Uq(2>5c$Qi724C+opH3j39R>APwR5!ZqW6QRk6y!SH*yf z+_m7mNxFHp5)I@5rJZ-*`uLm*H3f-#k!(JgX@-d?lSF!nj9e)! zAM*@D$~Uz`iLV8Y`o*eep)$PB3|dkdAz9O20be{jZ=66i3r%vO)jMC5)kwt7cRNOv zB{W-;fS~FJQ}q6;w4VAQN0VGNB_SO)~17%mAvPRIjvH6H;RgCDrCWH>}bK(`- zt0qVWoS6I*Xz*wDo8lD}9dlj9+HmL&-*!E{M4*oCmhs*EQ!9!Re&ZQt%?-|+8!`JE zdv@7BM_=(w6C;o{?<#7HXi&=?-P*}YBt5wLh@XG5S67*WI8F`@>*<}Cvgd|t=Bilt zLWW?Ae68;+W>;pVI?HyTW~CL~H$Xt%2-oMGR2{IC8TFa5R^f*W^2EFIxgoi7tpW&v z9F+;wO4mGdu>Hodu5&D74^bB`-?^6kQfMPlacM(gyIbY&nBUEmJ1B;zmYtZ7MX=BWyr{^{eLr=mpUiKO%z)zpKEKE(Q_)LN z@F@y=lw%J5Lrd?TIojYu4)K?r&AvluTum>vqXxw1?frk)A`jj8;JfGUYi8|eDVX0p z%ix0TNhKN6RMD9x=;9XQVuAt2m%|sS*lWeVli%Ewe7rP#u$9HAJ@fSWf`gSaan8H9 z;OnoKmy07KBWd{zu+uO9!AuGj0i0}Vt*9Sh){IuOI2hXwAi=estw>bXcSylOItU}^ zaGSff?wyCjQDNNNPtSh(lisR8WWTJeS9zwUq?9SJLn56e^I^@5^=r2!n?fw67B(>P zA}`>~4)yM7`o++wMW>opS-ns1I2QYLxwmC&T*&)irR;#_nbuJu$Qy%bS~dEnZvzhx*l z>2u2tLDsWu!?&yR#Hb}?~ru$Bi6KCRHLcUiQ8gg~Mdut{yeP*l@YW8~?9pZ&&09*$7_QUXLsnp>+b zK9u2L0CxW$0r}kwA7yyCN#iya`{Bcf()wipfoTt!)ZfnsQuK(pP^IbXz{Fon03p6d zmG7MgFJHyJf75l%pr+9`>_I#%h2Ousdrg?IQbx9WZO=$uqEq-Xf^J-Y{qKV-6B834 z!hK6fXd)!nYV#C)|E`iDUG4C8cdP{6pV9i3v?F7S z4K+2Ktj0vf1IMI>#|IisbfO2SDiL8(AegcU%gDgDoA|@R+7|Q-^`n`o#o=>|tXNl6 zBVnfQB9wZXFkzynx3;jbH{D?hJK8j<#R3{Xb}_3zt@X>mDkS~6)2|Cu@x>r$hb<9< zf#H|p0d~fvKVK%mj;VnztRJN1CYDNA4BXx^^GaMHdIv4KnAn*76I89OrL|pVFrukj#XKiVtq}`j#Od%zb0@>(LeRzz7w}KB`o@E%$DB^JEEP9 z39TOEv_IjA|31V);`_WR@|<~C>S0wq%HN^u^LoGc1(EhS z_>Ay_w3JD*K5IO#sBbvmj@ubPUskzxBz+1XX{cdYFKHVpYQuuwk(T-2)d$i)e~Ojk z456cET$ULfb+|qK?2yJ8P*t zkPypJgftGs$B*N9`}iaE{MWI^-{1k1N+wF?zyXL%47ZpcKHRwVZ;>&&>|1IopGi$% zSVTl59!PRHo;@RRiv%38-Fhg*t^8WsPKm>xs)!=5 zrX&|$>Bs49sgeZ0VWqsyjREQZ8x`JWHUWkyLFaPT7tBt%4hWGm3_bumyaGxj!`qkb zp|tZ-IqJ=H1FlIPvy@(^!M-IJ32J2Nbkbqrq=#vK?x&tV*q4=}TwFf=P5dW*gdaIU z;cq$OqZy?F(A!cLol$4&h0AYpoc9$pW0@q>Yq2Z5hLMrVN&M$^hUodeG-SWwk9W;t zL`^cIaIXL$nHejEv<9>hQ6dMC1F_Cd21D+~kig$*3S|#&l_xzj5w$*i%PQ_g_zm#o z)&A4Xc)F1=L`_YN+wLtu%U7rEe;Q)|)YDmG=8Pk#JEPLP34Tjk-!^n~lIYc#=b1DP zDs?&HDp*ho2kzld1s;1XertXi-o!`pUvt~EJWh9R+ZqeKbx!UFAi9goUMl2Y?v5Dy zUl7KYbhT`mGH!kS^z@XwWqb6+b0J>G-ks@{zXUCpNT3LC>?=StSc|8sz1?VK`6@ZrL!&a4lN)sXJ%5xyiZBhaBC6cnbmgFYG?D^il-Ygm>S z05sC7+D_P#H9Y)KI_w8PO@NMdWGW+i-w(S^_QLD{2qc9%^gHKNpcc8Sa$-Qo2m-{m zb@PVek{kEz3Pguk0U%RG4k(00ext?DtLyn9Qzq%)9**=BbcS?yRqlP=E@Zjl!u>LZ z)V>r2Ar2-CJAaBw#zZ#;1IN!lfnl4?rPIN6&#Ms2pMkAo@4O5G;LybM@1KGL9H{pq2n-vSWq>*w(@DMvY`vIXWG0vBU}6? z>^oxjGZVhrn>z_-dwF42=Abb;IvfPcZAt*>V8;1i4zfJsmo)P`3=?%icBMFxY+MC4 zYgnL)xl?Luh9a311}Qp-_=i81Z#DB!m+bBuo=YC00D?GX)g2m(EjNl)R8mY-vTBgb zjI7p7j@e3Q5Jw6qwwclr{r^=Z#-AUCy%E=em4<%#X&;ZOb}9geAUFg0r&fBZTT zx}B`6wK)SjnTj@)4+)WKxJgW0$Ie=$<#}>av?1aY+1twitB-y-g_-&5-iNA8&|2MQqJRYTX`D2tjdU zEzwuN#T=r#(uY#M`-i_4IpaI-$++h3y8sjUAEffm=p=x>`D(PcKHdVC3!ZUE<}pbJ}ixOTB2NT>==u=Fg!LG?b41ZV2gS z-UKbej*6DgM1sX5AMyRqWViWfC{Y%B%ZY!d32UnKc`p~0{F&gd$Km*xRNans37M|B zUIW8;XFEF#Os&v>R>F;-;iPz^#4Br+zM57`Pto{8Kuw#E{D;`A7 zqVR+L9lm}0`HKpq)9|G~mlK>|_mhdD35C4_nY7~(5t8VrduXQ#?&RLqGps=9O;2FR zS2hjc!+mX6HZOEBREh!B;+L3v;W;4U1l?RHsLu!kTUkl`CX1ny{>FcDPF$@K_jKj> z@-XTOpVV!?s;{oTcL0U2;noSeN1ynmvyx8swNJVQ!0wkF88b~6!PSKoC!Q3^y;2VX zaAhnD+d+B|FC;aF_wL;f)kg!(%an?$Y2Hb@*tPK10tj8B@8tXBkQm+a&KouRm)_W? z3=X}fI(|X!7>ND2gITEzkQXY-s9X{F;rb%2&Y_;KHK{o;A|j%--;_~2+soSXxTUSr z#!v9LBslBNjhmE3T94+vwivzFair~E>&C}_z25Th@X2H@&zM4@=$SOt)m`@N55HV% zE`8Q_>K7E(c5`WEMMf2|tnrOiJ!L+++2~ppKV>3UYsSEpgxbeIQ*KJGmN~Drdaro- zIaT^*v6k?`lR_x1Fy3x|Wk7FB(v&0rN#XZsg7cSGKatNx()NZXTACXu)K_C1fo_fe z0qbcP*xPff4E*)mP;mD0ICfabtEfQNx|yNCUo$ccM>y(@%2haedwSx!mo9xhHm0jf z_D#u}$VX)~hf6;?`%Z*WHzO0NOW=-{vTlSZshrx(D;2O9%10yRFDj?abd|qu-_hE^ zLJsVBX~-c7mjip&35z&kG@sd7*_qTPMb|DLBr5`g(=#g93l0M|mL=xPnFO@#^X{MC z%+Jp)hAPXk%B6cWH)}naKcI$;X8qxOmLgL!o>f`kgWFh7OG(u-Rtto_JB9uMZgft@ zG|<BO;0Y3pMmYp@bPnk)e6A zK?a=nc9P`LZl1e!r`zKyx^SV`hd?%O|EP9-ynMw&FG)#BE}~VAX}Bo6M@G}dXSsbQ zU!$nSWSIq6WVJDa^%VUAjyKYnSEdktfs%dC6qTgJcnfR$T)#+Crme*+b{&5P+N_De zZjU?VqWVFq|;SZZZ`eKsd3tU+NBPug+)cr zWm>bFn)cbOmE>5%!Af9w2$TKF>`7<^?cWTs{6uJV!v%ff1@{!v?x=lrMAxsit2^gc zM_D88C4G}&YCzJb95HzRo=ct%zUb)NNLmyxF`MpjCJO#N$H$ob=iQT=?D?#$_M7Vg z=i6;qEM9}OmM|}L4<|{-|_~2|`aM-<*GcljjDqQ)?Nt8vSo4PKul#hXt20ajf%+=#^zoa@r zH+GW0rUtBBX((oaloPI>7);~U_AI`hM7HOGPito522c)7R}3ks}^>b=YOT02sOT{gCZ zc613}X2 z-EHB<#;nvn1tm>1%727g?Ge`xrFrfuJS|4XymR{5xoYGd4?{C$WbkmciWIx?ssuN~*bo*{N_qfk~2n z534;K8g$N0cD%N+ap%OZ{VGCPi88Bob5`ne+roq7giW#g+qmJu@hP1BA@40%V@S;L zSih!%LSYId-q4FBWJ|{Pbcz&0j(mIxa}{M2u_>&k<=c^QW!+om!K_HH)9Eu!-})n^ z3Y2Y}3gL^+-s~-=i~39daCD%`aj8xFQCIVJplizO#QRUm+Y}>V-xD*coXV7*!2AK!EFuC6RfRf1#f7Ul$WJebPbu29lAzwEfDT)DInMaM0 zfV2grR_=jz?*2#H9RE+e_kmfu1MyGp&41+G|4q9K{`)!q`*w2t_gw$?T>rNX{NLLE c?h3TzBi;!Q{D=j<0S2Tfrv@&6WA^EP0F&imB>(^b literal 0 HcmV?d00001 diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 084ea480051..ecfe0db601a 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -132,6 +132,10 @@ const DEFAULT_CONFIG = { experimental_features: {}, oidc_providers: [], serve_server_wellknown: true, + presence: { + enabled: true, + include_offline_users_on_sync: true, + }, }; export type SynapseConfigOptions = Partial; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e9a53cd43cc..b966d62ddd1 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -278,9 +278,9 @@ @import "./views/rooms/_CallGuestLinkButton.pcss"; @import "./views/rooms/_DecryptionFailureBar.pcss"; @import "./views/rooms/_E2EIcon.pcss"; +@import "./views/rooms/_E2EIconView.pcss"; @import "./views/rooms/_EditMessageComposer.pcss"; @import "./views/rooms/_EmojiButton.pcss"; -@import "./views/rooms/_EntityTile.pcss"; @import "./views/rooms/_EventBubbleTile.pcss"; @import "./views/rooms/_EventPreview.pcss"; @import "./views/rooms/_EventTile.pcss"; @@ -290,13 +290,17 @@ @import "./views/rooms/_LinkPreviewGroup.pcss"; @import "./views/rooms/_LinkPreviewWidget.pcss"; @import "./views/rooms/_LiveContentSummary.pcss"; -@import "./views/rooms/_MemberList.pcss"; +@import "./views/rooms/_MemberListHeaderView.pcss"; +@import "./views/rooms/_MemberListView.pcss"; +@import "./views/rooms/_MemberTileView.pcss"; @import "./views/rooms/_MessageComposer.pcss"; @import "./views/rooms/_MessageComposerFormatBar.pcss"; @import "./views/rooms/_NewRoomIntro.pcss"; @import "./views/rooms/_NotificationBadge.pcss"; +@import "./views/rooms/_OverflowTile.pcss"; @import "./views/rooms/_PinnedEventTile.pcss"; @import "./views/rooms/_PinnedMessageBanner.pcss"; +@import "./views/rooms/_PresenceIconView.pcss"; @import "./views/rooms/_PresenceLabel.pcss"; @import "./views/rooms/_ReadReceiptGroup.pcss"; @import "./views/rooms/_ReplyPreview.pcss"; diff --git a/res/css/views/messages/_DisambiguatedProfile.pcss b/res/css/views/messages/_DisambiguatedProfile.pcss index c675ba99ed8..3f10c07abe3 100644 --- a/res/css/views/messages/_DisambiguatedProfile.pcss +++ b/res/css/views/messages/_DisambiguatedProfile.pcss @@ -21,8 +21,29 @@ Please see LICENSE files in the repository root for full details. } .mx_DisambiguatedProfile_mxid { - margin-inline-start: 5px; color: $secondary-content; font-size: var(--cpd-font-size-body-sm); + margin-inline-start: 5px; + } +} + +/** Disambiguated profile needs to have a different layout in the member tile */ +.mx_MemberTileView .mx_DisambiguatedProfile { + display: flex; + flex-direction: column; + + .mx_DisambiguatedProfile_mxid { + margin-inline-start: 0; + font: var(--cpd-font-body-sm-regular); + } + + span:not(.mx_DisambiguatedProfile_mxid) { + /** + In a member tile, this span element is a flex child and so + we need the following for text overflow to work. + **/ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } diff --git a/res/css/views/rooms/_E2EIconView.pcss b/res/css/views/rooms/_E2EIconView.pcss new file mode 100644 index 00000000000..3e2b1d95802 --- /dev/null +++ b/res/css/views/rooms/_E2EIconView.pcss @@ -0,0 +1,20 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_E2EIconView { + display: flex; + justify-content: center; + align-items: center; +} + +.mx_E2EIconView_warning { + color: var(--cpd-color-icon-critical-primary); +} + +.mx_E2EIconView_verified { + color: var(--cpd-color-icon-success-primary); +} diff --git a/res/css/views/rooms/_EntityTile.pcss b/res/css/views/rooms/_EntityTile.pcss deleted file mode 100644 index 43723dfb67e..00000000000 --- a/res/css/views/rooms/_EntityTile.pcss +++ /dev/null @@ -1,128 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. -Copyright 2015, 2016 OpenMarket Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_EntityTile { - display: flex; - align-items: center; - color: $primary-content; - cursor: pointer; - - .mx_E2EIcon { - margin: 0; - position: absolute; - bottom: 2px; - right: 7px; - } -} - -.mx_EntityTile:hover { - padding-right: 30px; - position: relative; /* to keep the chevron aligned */ -} - -.mx_EntityTile:hover::before { - content: ""; - position: absolute; - top: calc(50% - 8px); /* center */ - right: -8px; - mask: url("@vector-im/compound-design-tokens/icons/chevron-right.svg"); - mask-repeat: no-repeat; - mask-position: center; - width: 16px; - height: 16px; - background-color: $header-panel-text-primary-color; -} - -.mx_EntityTile:not(.mx_EntityTile_unreachable) .mx_PresenceLabel { - display: none; -} - -.mx_EntityTile:hover .mx_PresenceLabel { - display: block; -} - -.mx_EntityTile_invite { - display: table-cell; - vertical-align: middle; - margin-left: 10px; - width: 26px; -} - -.mx_EntityTile_avatar { - padding-left: 3px; - padding-right: 12px; - padding-top: 4px; - padding-bottom: 4px; - position: relative; - line-height: 0; -} - -.mx_EntityTile_name { - flex: 1 1 0; - overflow: hidden; - font: var(--cpd-font-body-md-regular); - text-overflow: ellipsis; - white-space: nowrap; -} - -.mx_EntityTile_details { - overflow: hidden; - flex: 1; -} - -.mx_EntityTile_ellipsis .mx_EntityTile_name { - font-style: italic; - color: $primary-content; -} - -.mx_EntityTile_invitePlaceholder .mx_EntityTile_name { - font-style: italic; - color: $primary-content; -} - -.mx_EntityTile_unavailable .mx_EntityTile_avatar, -.mx_EntityTile_unavailable .mx_EntityTile_name, -.mx_EntityTile_offline_beenactive .mx_EntityTile_avatar, -.mx_EntityTile_offline_beenactive .mx_EntityTile_name { - opacity: 0.5; -} - -.mx_EntityTile_offline_neveractive .mx_EntityTile_avatar, -.mx_EntityTile_offline_neveractive .mx_EntityTile_name { - opacity: 0.25; -} - -.mx_EntityTile_unknown .mx_EntityTile_avatar, -.mx_EntityTile_unknown .mx_EntityTile_name, -.mx_EntityTile_unreachable .mx_EntityTile_avatar, -.mx_EntityTile_unreachable .mx_EntityTile_name { - opacity: 0.25; -} - -.mx_EntityTile_subtext { - font-size: $font-11px; - opacity: 0.5; - overflow: hidden; - white-space: nowrap; - text-overflow: clip; -} - -.mx_EntityTile_power { - padding-inline-start: 6px; - font-size: $font-10px; - color: $secondary-content; - max-width: 6em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mx_EntityTile:hover .mx_EntityTile_power { - display: none; -} diff --git a/res/css/views/rooms/_MemberList.pcss b/res/css/views/rooms/_MemberList.pcss deleted file mode 100644 index 93d390f7574..00000000000 --- a/res/css/views/rooms/_MemberList.pcss +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2015, 2016 OpenMarket Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_MemberList { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; - - .mx_Spinner { - flex: 1 0 auto; - } - - .mx_SearchBox { - margin-bottom: 5px; - } - - h2 { - text-transform: uppercase; - color: $h3-color; - font-weight: var(--cpd-font-weight-semibold); - font-size: $font-13px; - padding-left: 3px; - padding-right: 12px; - margin-top: 8px; - margin-bottom: 4px; - } - - .mx_AutoHideScrollbar { - flex: 1 1 0; - margin-top: var(--cpd-space-3x); - } -} - -.mx_MemberList_chevron { - position: absolute; - right: 35px; - margin-top: -15px; -} - -.mx_MemberList_border { - overflow-y: auto; - - order: 1; - flex: 1 1 0px; -} - -.mx_MemberList_query { - height: 16px; - - /* stricter rule to override the one in _common.pcss */ - &[type="text"] { - font-size: $font-12px; - } -} - -.mx_MemberList_wrapper { - padding: 10px; -} - -.mx_MemberList_invite { - margin: 0 var(--cpd-space-2x); - width: calc(100% - var(--cpd-space-4x)); -} diff --git a/res/css/views/rooms/_MemberListHeaderView.pcss b/res/css/views/rooms/_MemberListHeaderView.pcss new file mode 100644 index 00000000000..326cf84dd6c --- /dev/null +++ b/res/css/views/rooms/_MemberListHeaderView.pcss @@ -0,0 +1,37 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_MemberListHeaderView { + border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-400); + max-height: 112px; + + .mx_MemberListHeaderView_container { + margin-top: var(--cpd-space-6x); + width: 100%; + } + + .mx_MemberListHeaderView_invite_small { + margin-left: var(--cpd-space-3x); + } + + .mx_MemberListHeaderView_invite_large { + width: 288px; + height: 36px; + } + + .mx_MemberListHeaderView_label { + padding: var(--cpd-space-6x) 0 var(--cpd-space-2x) var(--cpd-space-4x); + box-sizing: border-box; + width: 100%; + color: var(--cpd-color-text-secondary); + font: var(--cpd-font-body-sm-semibold); + } + + .mx_MemberListHeaderView_search { + width: 240px; + } +} diff --git a/res/css/views/rooms/_MemberListView.pcss b/res/css/views/rooms/_MemberListView.pcss new file mode 100644 index 00000000000..e13b17b226e --- /dev/null +++ b/res/css/views/rooms/_MemberListView.pcss @@ -0,0 +1,17 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_MemberListView { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + + .mx_MemberListView_container { + height: 100%; + } +} diff --git a/res/css/views/rooms/_MemberTileView.pcss b/res/css/views/rooms/_MemberTileView.pcss new file mode 100644 index 00000000000..702edd8f9d2 --- /dev/null +++ b/res/css/views/rooms/_MemberTileView.pcss @@ -0,0 +1,58 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_MemberTileView { + display: flex; + padding: var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-4x); + box-sizing: border-box; + height: 56px; + border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-300); + + .mx_MemberTileView_left, + .mx_MemberTileView_right { + display: flex; + align-items: center; + gap: var(--cpd-space-2x); + } + + .mx_MemberTileView_left { + flex-basis: 209px; + flex-grow: 1; + min-width: 0; + } + + .mx_MemberTileView_name { + font: var(--cpd-font-body-md-medium); + font-size: 15px; + min-width: 0; + } + + .mx_MemberTileView_user_label { + font: var(--cpd-font-body-sm-regular); + font-size: 13px; + } + + .mx_MemberTileView_avatar { + position: relative; + height: 32px; + width: 32px; + } + + .mx_E2EIconView { + display: flex; + justify-content: center; + align-items: center; + } + + .mx_E2EIconView_warning { + color: var(--cpd-color-icon-critical-primary); + } + + .mx_E2EIconView_verified { + color: var(--cpd-color-icon-success-primary); + } +} diff --git a/res/css/views/rooms/_OverflowTile.pcss b/res/css/views/rooms/_OverflowTile.pcss new file mode 100644 index 00000000000..a0ac5bb4f61 --- /dev/null +++ b/res/css/views/rooms/_OverflowTile.pcss @@ -0,0 +1,51 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016 OpenMarket Ltd + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_OverflowTileView { + display: flex; + align-items: center; + color: $primary-content; + cursor: pointer; +} + +.mx_OverflowTileView_text { + flex: 1 1 0; + overflow: hidden; + font: var(--cpd-font-body-md-regular); + text-overflow: ellipsis; + white-space: nowrap; + font-style: italic; +} + +.mx_OverflowTileView:hover { + padding-right: 30px; + position: relative; /* to keep the chevron aligned */ +} + +.mx_OverflowTileView:hover::before { + content: ""; + position: absolute; + top: calc(50% - 8px); /* center */ + right: -8px; + mask: url("@vector-im/compound-design-tokens/icons/chevron-right.svg"); + mask-repeat: no-repeat; + mask-position: center; + width: 16px; + height: 16px; + background-color: $header-panel-text-primary-color; +} + +.mx_OverflowTileView_icon { + padding-left: 3px; + padding-right: 12px; + padding-top: 4px; + padding-bottom: 4px; + position: relative; + line-height: 0; +} diff --git a/res/css/views/rooms/_PresenceIconView.pcss b/res/css/views/rooms/_PresenceIconView.pcss new file mode 100644 index 00000000000..e09fbdf2fa1 --- /dev/null +++ b/res/css/views/rooms/_PresenceIconView.pcss @@ -0,0 +1,32 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_PresenceIconView { + position: absolute; + top: 24px; + left: 24px; + width: 12px; + height: 12px; + display: flex; + justify-content: center; + align-items: center; + background: var(--cpd-color-bg-canvas-default); + border-radius: 100%; + + .mx_PresenceIconView_online { + color: var(--cpd-color-icon-accent-primary); + } + + .mx_PresenceIconView_offline, + .mx_PresenceIconView_dnd { + color: var(--cpd-color-icon-tertiary); + } + + .mx_PresenceIconView_unavailable { + color: var(--cpd-color-icon-quaternary); + } +} diff --git a/src/components/structures/MainSplit.tsx b/src/components/structures/MainSplit.tsx index 79ba7ab7dbf..540aec251f0 100644 --- a/src/components/structures/MainSplit.tsx +++ b/src/components/structures/MainSplit.tsx @@ -99,7 +99,7 @@ export default class MainSplit extends React.Component { ; interface IState { phase?: RightPanelPhases; - searchQuery: string; cardState?: IRightPanelCardState; } @@ -67,10 +66,6 @@ export default class RightPanel extends React.Component { public constructor(props: Props, context: React.ContextType) { super(props, context); - - this.state = { - searchQuery: "", - }; } private readonly delayedUpdate = throttle( @@ -147,10 +142,6 @@ export default class RightPanel extends React.Component { } }; - private onSearchQueryChanged = (searchQuery: string): void => { - this.setState({ searchQuery }); - }; - public render(): React.ReactNode { let card =
; const roomId = this.props.room?.roomId; @@ -159,15 +150,7 @@ export default class RightPanel extends React.Component { switch (phase) { case RightPanelPhases.MemberList: if (!!roomId) { - card = ( - - ); + card = ; } break; diff --git a/src/components/viewmodels/memberlist/MemberListViewModel.tsx b/src/components/viewmodels/memberlist/MemberListViewModel.tsx new file mode 100644 index 00000000000..4a1a2d59f19 --- /dev/null +++ b/src/components/viewmodels/memberlist/MemberListViewModel.tsx @@ -0,0 +1,263 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { + ClientEvent, + EventType, + MatrixEvent, + Room, + RoomEvent, + RoomMemberEvent, + RoomState, + RoomStateEvent, + RoomMember as SdkRoomMember, + User, + UserEvent, +} from "matrix-js-sdk/src/matrix"; +import { KnownMembership } from "matrix-js-sdk/src/types"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { throttle } from "lodash"; + +import { RoomMember } from "../../../models/rooms/RoomMember"; +import { mediaFromMxc } from "../../../customisations/Media"; +import UserIdentifierCustomisations from "../../../customisations/UserIdentifier"; +import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; +import { UIComponent } from "../../../settings/UIFeature"; +import { PresenceState } from "../../../models/rooms/PresenceState"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import { SDKContext } from "../../../contexts/SDKContext"; +import PosthogTrackers from "../../../PosthogTrackers"; +import { ButtonEvent } from "../../views/elements/AccessibleButton"; +import { inviteToRoom } from "../../../utils/room/inviteToRoom"; +import { canInviteTo } from "../../../utils/room/canInviteTo"; +import { isValid3pidInvite } from "../../../RoomInvite"; +import { ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite"; +import { XOR } from "../../../@types/common"; +import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; + +type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>; + +export function getPending3PidInvites(room: Room, searchQuery?: string): Member[] { + // include 3pid invites (m.room.third_party_invite) state events. + // The HS may have already converted these into m.room.member invites so + // we shouldn't add them if the 3pid invite state key (token) is in the + // member invite (content.third_party_invite.signed.token) + const inviteEvents = room.currentState.getStateEvents("m.room.third_party_invite").filter(function (e) { + if (!isValid3pidInvite(e)) return false; + if (searchQuery && !(e.getContent().display_name as string)?.includes(searchQuery)) return false; + + // discard all invites which have a m.room.member event since we've + // already added them. + const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey()!); + if (memberEvent) return false; + return true; + }); + const invites: Member[] = inviteEvents.map((e) => { + return { + threePidInvite: { + event: e, + }, + }; + }); + return invites; +} + +export function sdkRoomMemberToRoomMember(member: SdkRoomMember): Member { + const displayUserId = + UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { + roomId: member.roomId, + }) ?? member.userId; + + const mxcAvatarURL = member.getMxcAvatarUrl(); + const avatarThumbnailUrl = + (mxcAvatarURL && mediaFromMxc(mxcAvatarURL).getThumbnailOfSourceHttp(96, 96, "crop")) ?? undefined; + + const user = member.user; + let presenceState: PresenceState | undefined; + if (user) { + presenceState = (user.presence as PresenceState) || undefined; + } + + return { + member: { + roomId: member.roomId, + userId: member.userId, + displayUserId: displayUserId, + name: member.name, + rawDisplayName: member.rawDisplayName, + disambiguate: member.disambiguate, + avatarThumbnailUrl: avatarThumbnailUrl, + powerLevel: member.powerLevel, + lastModifiedTime: member.getLastModifiedTime(), + presenceState, + isInvite: member.membership === KnownMembership.Invite, + }, + }; +} + +export interface MemberListViewState { + members: Member[]; + search: (searchQuery: string) => void; + isPresenceEnabled: boolean; + shouldShowInvite: boolean; + shouldShowSearch: boolean; + isLoading: boolean; + canInvite: boolean; + onInviteButtonClick: (ev: ButtonEvent) => void; +} +export function useMemberListViewModel(roomId: string): MemberListViewState { + const cli = useMatrixClientContext(); + + const room = useMemo(() => cli.getRoom(roomId), [roomId, cli]); + if (!room) { + throw new Error(`Room with id ${roomId} does not exist!`); + } + + const sdkContext = useContext(SDKContext); + const [memberMap, setMemberMap] = useState>(new Map()); + const [isLoading, setIsLoading] = useState(true); + + // This is the last known total number of members in this room. + const totalMemberCount = useRef(0); + + const searchQuery = useRef(""); + + const loadMembers = useMemo( + () => + throttle( + async (): Promise => { + const { joined: joinedSdk, invited: invitedSdk } = await sdkContext.memberListStore.loadMemberList( + roomId, + searchQuery.current, + ); + const newMemberMap = new Map(); + // First add the invited room members + for (const member of invitedSdk) { + const roomMember = sdkRoomMemberToRoomMember(member); + newMemberMap.set(member.userId, roomMember); + } + // Then add the third party invites + const threePidInvited = getPending3PidInvites(room, searchQuery.current); + for (const invited of threePidInvited) { + const key = invited.threePidInvite!.event.getContent().display_name; + newMemberMap.set(key, invited); + } + // Finally add the joined room members + for (const member of joinedSdk) { + const roomMember = sdkRoomMemberToRoomMember(member); + newMemberMap.set(member.userId, roomMember); + } + setMemberMap(newMemberMap); + if (!searchQuery.current) { + /** + * Since searching for members only gives you the relevant + * members matching the query, do not update the totalMemberCount! + **/ + totalMemberCount.current = newMemberMap.size; + } + }, + 500, + { leading: true, trailing: true }, + ), + [roomId, sdkContext.memberListStore, room], + ); + + const search = useCallback( + (query: string) => { + searchQuery.current = query; + loadMembers(); + }, + [loadMembers], + ); + + const isPresenceEnabled = useMemo( + () => sdkContext.memberListStore.isPresenceEnabled(), + [sdkContext.memberListStore], + ); + + // Determines whether the rendered invite button is enabled or disabled + const getCanUserInviteToThisRoom = useCallback((): boolean => !!room && canInviteTo(room), [room]); + const [canInvite, setCanInvite] = useState(getCanUserInviteToThisRoom()); + + // Determines whether the invite button should be shown or not. + const getShouldShowInvite = useCallback( + (): boolean => room?.getMyMembership() === KnownMembership.Join && shouldShowComponent(UIComponent.InviteUsers), + [room], + ); + const [shouldShowInvite, setShouldShowInvite] = useState(getShouldShowInvite()); + + const onInviteButtonClick = (ev: ButtonEvent): void => { + PosthogTrackers.trackInteraction("WebRightPanelMemberListInviteButton", ev); + ev.preventDefault(); + inviteToRoom(room); + }; + + useTypedEventEmitter(cli, RoomStateEvent.Events, (event: MatrixEvent) => { + if (event.getRoomId() === roomId && event.getType() === EventType.RoomThirdPartyInvite) { + loadMembers(); + const newCanInvite = getCanUserInviteToThisRoom(); + setCanInvite(newCanInvite); + } + }); + + useTypedEventEmitter(cli, RoomStateEvent.Update, (state: RoomState) => { + if (state.roomId === roomId) loadMembers(); + }); + + useTypedEventEmitter(cli, RoomMemberEvent.Name, (_: MatrixEvent, member: SdkRoomMember) => { + if (member.roomId === roomId) loadMembers(); + }); + + useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => { + // We listen for room events because when we accept an invite + // we need to wait till the room is fully populated with state + // before refreshing the member list else we get a stale list. + if (room.roomId === roomId) loadMembers(); + }); + + useTypedEventEmitter(cli, RoomEvent.MyMembership, (room: Room, membership: string, oldMembership?: string) => { + if (room.roomId !== roomId) return; + if (membership === KnownMembership.Join && oldMembership !== KnownMembership.Join) { + // we just joined the room, load the member list + loadMembers(); + const newShouldShowInvite = getShouldShowInvite(); + setShouldShowInvite(newShouldShowInvite); + } + }); + + useTypedEventEmitter(cli, UserEvent.Presence, (_: MatrixEvent | undefined, user: User) => { + if (memberMap.has(user.userId)) loadMembers(); + }); + + useTypedEventEmitter(cli, UserEvent.CurrentlyActive, (_: MatrixEvent | undefined, user: User) => { + if (memberMap.has(user.userId)) loadMembers(); + }); + + // Initial load of the memberlist + useEffect(() => { + (async () => { + await loadMembers(); + /** + * isLoading is used to render a spinner on initial call. + * Further calls need not mutate this state since it's perfectly fine to + * show the existing memberlist until the new one loads. + */ + setIsLoading(false); + })(); + }, [loadMembers]); + + return { + members: Array.from(memberMap.values()), + search, + shouldShowInvite, + isPresenceEnabled, + isLoading, + onInviteButtonClick, + shouldShowSearch: totalMemberCount.current >= 20, + canInvite, + }; +} diff --git a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx new file mode 100644 index 00000000000..ade40e9b97b --- /dev/null +++ b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx @@ -0,0 +1,160 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useEffect, useMemo, useState } from "react"; +import { RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; +import { UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; + +import dis from "../../../../dispatcher/dispatcher"; +import { MatrixClientPeg } from "../../../../MatrixClientPeg"; +import { Action } from "../../../../dispatcher/actions"; +import { asyncSome } from "../../../../utils/arrays"; +import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo"; +import { RoomMember } from "../../../../models/rooms/RoomMember"; +import { E2EState } from "../../../views/rooms/E2EIcon"; +import { _t, _td, TranslationKey } from "../../../../languageHandler"; +import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier"; + +interface MemberTileViewModelProps { + member: RoomMember; + showPresence?: boolean; +} + +export interface MemberTileViewState extends MemberTileViewModelProps { + e2eStatus?: E2EState; + name: string; + onClick: () => void; + title?: string; + userLabel?: string; +} + +export enum PowerStatus { + Admin = "admin", + Moderator = "moderator", +} + +const PowerLabel: Record = { + [PowerStatus.Admin]: _td("power_level|admin"), + [PowerStatus.Moderator]: _td("power_level|moderator"), +}; + +export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberTileViewState { + const [e2eStatus, setE2eStatus] = useState(); + + useEffect(() => { + const cli = MatrixClientPeg.safeGet(); + + const updateE2EStatus = async (): Promise => { + const { userId } = props.member; + const isMe = userId === cli.getUserId(); + const userTrust = await cli.getCrypto()?.getUserVerificationStatus(userId); + if (!userTrust?.isCrossSigningVerified()) { + setE2eStatus(userTrust?.wasCrossSigningVerified() ? E2EState.Warning : E2EState.Normal); + return; + } + + const deviceIDs = await getUserDeviceIds(cli, userId); + const anyDeviceUnverified = await asyncSome(deviceIDs, async (deviceId) => { + // For your own devices, we use the stricter check of cross-signing + // verification to encourage everyone to trust their own devices via + // cross-signing so that other users can then safely trust you. + // For other people's devices, the more general verified check that + // includes locally verified devices can be used. + const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId); + return !deviceTrust || (isMe ? !deviceTrust.crossSigningVerified : !deviceTrust.isVerified()); + }); + setE2eStatus(anyDeviceUnverified ? E2EState.Warning : E2EState.Verified); + }; + + const onRoomStateEvents = (ev: MatrixEvent): void => { + if (ev.getType() !== EventType.RoomEncryption) return; + const { roomId } = props.member; + if (ev.getRoomId() !== roomId) return; + + // The room is encrypted now. + cli.removeListener(RoomStateEvent.Events, onRoomStateEvents); + updateE2EStatus(); + }; + + const onUserTrustStatusChanged = (userId: string, trustStatus: UserVerificationStatus): void => { + if (userId !== props.member.userId) return; + updateE2EStatus(); + }; + + const { roomId } = props.member; + if (roomId) { + const isRoomEncrypted = cli.isRoomEncrypted(roomId); + if (isRoomEncrypted) { + cli.on(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged); + updateE2EStatus(); + } else { + // Listen for room to become encrypted + cli.on(RoomStateEvent.Events, onRoomStateEvents); + } + } + + return () => { + if (cli) { + cli.removeListener(RoomStateEvent.Events, onRoomStateEvents); + cli.removeListener(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged); + } + }; + }, [props.member]); + + const onClick = (): void => { + dis.dispatch({ + action: Action.ViewUser, + member: props.member, + push: true, + }); + }; + + const member = props.member; + const name = props.member.name; + + const powerStatusMap = new Map([ + [100, PowerStatus.Admin], + [50, PowerStatus.Moderator], + ]); + + // Find the nearest power level with a badge + let powerLevel = props.member.powerLevel; + for (const [pl] of powerStatusMap) { + if (props.member.powerLevel >= pl) { + powerLevel = pl; + break; + } + } + + const title = useMemo(() => { + return _t("member_list|power_label", { + userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { + roomId: member.roomId, + }), + powerLevelNumber: member.powerLevel, + }).trim(); + }, [member.powerLevel, member.roomId, member.userId]); + + let userLabel; + const powerStatus = powerStatusMap.get(powerLevel); + if (powerStatus) { + userLabel = _t(PowerLabel[powerStatus]); + } + if (props.member.isInvite) { + userLabel = `(${_t("member_list|invited_label")})`; + } + + return { + title, + member, + name, + onClick, + e2eStatus, + showPresence: props.showPresence, + userLabel, + }; +} diff --git a/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx new file mode 100644 index 00000000000..daeb8d899f4 --- /dev/null +++ b/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx @@ -0,0 +1,35 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import dis from "../../../../dispatcher/dispatcher"; +import { Action } from "../../../../dispatcher/actions"; +import { ThreePIDInvite } from "../../../../models/rooms/ThreePIDInvite"; + +interface ThreePidTileViewModelProps { + threePidInvite: ThreePIDInvite; +} + +export interface ThreePidTileViewState { + name: string; + onClick: () => void; +} + +export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): ThreePidTileViewState { + const invite = props.threePidInvite; + const name = invite.event.getContent().display_name; + const onClick = (): void => { + dis.dispatch({ + action: Action.View3pidInvite, + event: invite.event, + }); + }; + + return { + name, + onClick, + }; +} diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index dc6186a868c..a831acc5d18 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -23,8 +23,6 @@ import { TimelineEvents, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; -// eslint-disable-next-line no-restricted-imports -import OverflowHorizontalSvg from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; @@ -42,8 +40,6 @@ import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; import QueryMatcher from "../../../autocomplete/QueryMatcher"; import TruncatedList from "../elements/TruncatedList"; -import EntityTile from "../rooms/EntityTile"; -import BaseAvatar from "../avatars/BaseAvatar"; import { Action } from "../../../dispatcher/actions"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; @@ -60,6 +56,7 @@ import { } from "../../../accessibility/RovingTabIndex"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { OverflowTileView } from "../rooms/OverflowTileView"; const AVATAR_SIZE = 30; @@ -275,17 +272,9 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr } const [truncateAt, setTruncateAt] = useState(20); + function overflowTile(overflowCount: number, totalCount: number): JSX.Element { - const text = _t("common|and_n_others", { count: overflowCount }); - return ( - } - name={text} - showPresence={false} - onClick={() => setTruncateAt(totalCount)} - /> - ); + return setTruncateAt(totalCount)} />; } const onKeyDown = (ev: React.KeyboardEvent, state: IState): void => { diff --git a/src/components/views/messages/DisambiguatedProfile.tsx b/src/components/views/messages/DisambiguatedProfile.tsx index 4357ac73e0a..660a832d373 100644 --- a/src/components/views/messages/DisambiguatedProfile.tsx +++ b/src/components/views/messages/DisambiguatedProfile.tsx @@ -8,15 +8,21 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { RoomMember } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; import { getUserNameColorClass } from "../../../utils/FormattingUtils"; import UserIdentifier from "../../../customisations/UserIdentifier"; +interface MemberInfo { + userId: string; + roomId: string; + rawDisplayName?: string; + disambiguate: boolean; +} + interface IProps { - member?: RoomMember | null; + member?: MemberInfo | null; fallbackName: string; onClick?(): void; colored?: boolean; diff --git a/src/components/views/rooms/E2EIcon.tsx b/src/components/views/rooms/E2EIcon.tsx index 3eb789914ba..5366f7621cf 100644 --- a/src/components/views/rooms/E2EIcon.tsx +++ b/src/components/views/rooms/E2EIcon.tsx @@ -22,7 +22,7 @@ export enum E2EState { Normal = "normal", } -const crossSigningUserTitles: { [key in E2EState]?: TranslationKey } = { +export const crossSigningUserTitles: { [key in E2EState]?: TranslationKey } = { [E2EState.Warning]: _td("encryption|cross_signing_user_warning"), [E2EState.Normal]: _td("encryption|cross_signing_user_normal"), [E2EState.Verified]: _td("encryption|cross_signing_user_verified"), diff --git a/src/components/views/rooms/EntityTile.tsx b/src/components/views/rooms/EntityTile.tsx deleted file mode 100644 index af4a265b72f..00000000000 --- a/src/components/views/rooms/EntityTile.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. -Copyright 2018 New Vector Ltd -Copyright 2015, 2016 OpenMarket Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import classNames from "classnames"; - -import AccessibleButton from "../elements/AccessibleButton"; -import { _t, _td, TranslationKey } from "../../../languageHandler"; -import E2EIcon, { E2EState } from "./E2EIcon"; -import BaseAvatar from "../avatars/BaseAvatar"; -import PresenceLabel from "./PresenceLabel"; - -export enum PowerStatus { - Admin = "admin", - Moderator = "moderator", -} - -const PowerLabel: Record = { - [PowerStatus.Admin]: _td("power_level|admin"), - [PowerStatus.Moderator]: _td("power_level|mod"), -}; - -export type PresenceState = "offline" | "online" | "unavailable" | "io.element.unreachable"; - -const PRESENCE_CLASS: Record = { - "offline": "mx_EntityTile_offline", - "online": "mx_EntityTile_online", - "unavailable": "mx_EntityTile_unavailable", - "io.element.unreachable": "mx_EntityTile_unreachable", -}; - -function presenceClassForMember(presenceState?: PresenceState, lastActiveAgo?: number, showPresence?: boolean): string { - if (showPresence === false) { - return "mx_EntityTile_online_beenactive"; - } - - // offline is split into two categories depending on whether we have - // a last_active_ago for them. - if (presenceState === "offline") { - if (lastActiveAgo) { - return PRESENCE_CLASS["offline"] + "_beenactive"; - } else { - return PRESENCE_CLASS["offline"] + "_neveractive"; - } - } else if (presenceState) { - return PRESENCE_CLASS[presenceState]; - } else { - return PRESENCE_CLASS["offline"] + "_neveractive"; - } -} - -interface IProps { - name?: string; - nameJSX?: JSX.Element; - title?: string; - avatarJsx?: JSX.Element; // - className?: string; - presenceState: PresenceState; - presenceLastActiveAgo: number; - presenceLastTs: number; - presenceCurrentlyActive?: boolean; - onClick(): void; - showPresence: boolean; - subtextLabel?: string; - e2eStatus?: E2EState; - powerStatus?: PowerStatus; -} - -interface IState { - hover: boolean; -} - -export default class EntityTile extends React.PureComponent { - public static defaultProps = { - onClick: () => {}, - presenceState: "offline", - presenceLastActiveAgo: 0, - presenceLastTs: 0, - showInviteButton: false, - showPresence: true, - }; - - public constructor(props: IProps) { - super(props); - - this.state = { - hover: false, - }; - } - - /** - * Creates the PresenceLabel component if needed - * @returns The PresenceLabel component if we need to render it, undefined otherwise - */ - private getPresenceLabel(): JSX.Element | undefined { - if (!this.props.showPresence) return; - const activeAgo = this.props.presenceLastActiveAgo - ? Date.now() - (this.props.presenceLastTs - this.props.presenceLastActiveAgo) - : -1; - return ( - - ); - } - - public render(): React.ReactNode { - const mainClassNames: Record = { - mx_EntityTile: true, - }; - if (this.props.className) mainClassNames[this.props.className] = true; - - const presenceClass = presenceClassForMember( - this.props.presenceState, - this.props.presenceLastActiveAgo, - this.props.showPresence, - ); - mainClassNames[presenceClass] = true; - - const name = this.props.nameJSX || this.props.name; - const nameAndPresence = ( -
-
{name}
- {this.getPresenceLabel()} -
- ); - - let powerLabel; - const powerStatus = this.props.powerStatus; - if (powerStatus) { - const powerText = _t(PowerLabel[powerStatus]); - powerLabel =
{powerText}
; - } - - let e2eIcon; - const { e2eStatus } = this.props; - if (e2eStatus) { - e2eIcon = ; - } - - const av = this.props.avatarJsx ||