From f4c62abbcda9a3d5beccfcc0ea62838df4ab57de Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 22 Apr 2026 21:50:54 +0200 Subject: [PATCH] Room list: assign room to custom section (#33238) * feat(sc): add new toast type for room list * feat(sc): add section entries in room list item menu * feat(rls): expose util functions * feat: allows to tag room with custom sections * feat(vm): add new Chat moved toast to room list vm * feat(vm): add section selection to room list item vm * feat(e2e): add tests for adding room in a custom section * test(e2e): update existing screenshots * chore: fix lint after merge * chore: remove outline in test --- .../room-list-custom-sections.spec.ts | 89 ++++++++++++++++ ...m-list-sections-chat-moved-toast-linux.png | Bin 0 -> 7782 bytes .../room-list-sections-collapsed-linux.png | Bin 7286 -> 9836 bytes .../room-list-sections-linux.png | Bin 15370 -> 17895 bytes .../stores/room-list-v3/RoomListStoreV3.ts | 11 ++ apps/web/src/stores/room-list-v3/section.ts | 16 ++- apps/web/src/utils/room/tagRoom.ts | 25 +++-- .../room-list/RoomListItemViewModel.ts | 54 +++++++++- .../viewmodels/room-list/RoomListViewModel.ts | 29 ++++-- .../unit-tests/utils/room/tagRoom-test.ts | 42 +++++++- .../room-list/RoomListItemViewModel-test.tsx | 97 +++++++++++++++++- .../room-list/RoomListViewModel-test.tsx | 6 ++ .../chat-moved-auto.png | Bin 0 -> 6035 bytes .../src/i18n/strings/en_EN.json | 1 + .../RoomListToast/RoomListToast.stories.tsx | 6 ++ .../RoomListToast/RoomListToast.test.tsx | 7 +- .../RoomListToast/RoomListToast.tsx | 5 +- .../__snapshots__/RoomListToast.test.tsx.snap | 56 ++++++++++ .../src/room-list/RoomListView/index.tsx | 1 + .../RoomListItemMoreOptionsMenu.test.tsx | 56 ++++++++++ .../RoomListItemMoreOptionsMenu.tsx | 16 +++ .../RoomListItemNotificationMenu.test.tsx | 1 + .../RoomListItemView.stories.tsx | 2 + .../RoomListItemView/RoomListItemView.tsx | 17 +++ .../RoomListItemView/default-snapshot.ts | 17 +++ .../RoomListItemView/index.ts | 1 + .../RoomListItemView/mocked-actions.ts | 1 + .../src/room-list/story-mocks.tsx | 2 + 28 files changed, 532 insertions(+), 26 deletions(-) create mode 100644 apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-custom-sections.spec.ts/room-list-sections-chat-moved-toast-linux.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx/chat-moved-auto.png diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts index a390d3e610..01146ef0dd 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts @@ -175,4 +175,93 @@ test.describe("Room list custom sections", () => { await expect(getSectionHeader(page, "Low Priority")).toBeVisible(); }); }); + + test.describe("Adding a room to a custom section", () => { + /** + * Asserts a room is nested under a specific section using the treegrid aria-level hierarchy. + * Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2. + * Verifies that the closest preceding aria-level=1 row is the expected section header. + */ + async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise { + const roomList = getRoomList(page); + const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` }); + // Room row must be at aria-level=2 (i.e. inside a section) + await expect(roomRow).toHaveAttribute("aria-level", "2"); + // The closest preceding aria-level=1 row must be the expected section header. + // XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one. + const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`); + await expect(closestSectionHeader).toContainText(sectionName); + } + + test("should add a room to a custom section via the More Options menu", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + const roomList = getRoomList(page); + + // Room starts in Chats section (aria-level=2) + const roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await expect(roomItem).toBeVisible(); + + // Open More Options and move to the Work section + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + // Room should now be nested under the Work section header (aria-level=1 → aria-level=2) + await assertRoomInSection(page, "Work", "my room"); + }); + + test( + "should show 'Chat moved' toast when adding a room to a custom section", + { tag: "@screenshot" }, + async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + const roomList = getRoomList(page); + const roomItem = roomList.getByRole("row", { name: "Open room my room" }); + + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + // The "Chat moved" toast should appear + await expect(page.getByText("Chat moved")).toBeVisible(); + + // Remove focus outline from the room item before taking the screenshot + await page.getByRole("button", { name: "User menu" }).focus(); + + await expect(roomList).toMatchScreenshot("room-list-sections-chat-moved-toast.png"); + }, + ); + + test("should remove a room from a custom section when toggling the same section", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + const roomList = getRoomList(page); + + // Move to Work section and verify placement via aria-level + let roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + await assertRoomInSection(page, "Work", "my room"); + + // Toggle off by selecting the same section again + roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + // Room is back in the Chats section + await assertRoomInSection(page, "Chats", "my room"); + }); + }); }); diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-custom-sections.spec.ts/room-list-sections-chat-moved-toast-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-custom-sections.spec.ts/room-list-sections-chat-moved-toast-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..4da4571cbcbe215bd5ef6831fe4694155db21960 GIT binary patch literal 7782 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 UIIpuYrxt*YmZ4_(W5<{O3*om;9RL6T literal 0 HcmV?d00001 diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png index 6ebcef8532734064ea85453d0fa0bbbe8d5dd6dd..1f5246ceaf1a28b2e7c63e98745b43b4b14d8b9a 100644 GIT binary patch delta 8255 zcmV-FAi&@DIP6T2Eq@7EROSA_@mFOSFlS5w7Z$I8ii(L!xFlD}6}(Djsn>nYTvFRC zEltfz+*5Pkb1C&|iYA&%=*3)6QA%=QNdY%Nu0t0b2l0OZ?N(3f06+hwd_Rv*51cvY zFg4BB_dVxu#^%nQI|=}V*r)=!eLLrtCHrQn`VpomPG+5HuSbSJH5{O^{s7+HY)jVmRTE`C;-KbHfDLs-d6jkRVyp&+_Kyz zC_wR~4Jot&Sh=!QP0@~_Fy#S?GpztxS*%&ps-|cMWfg$p&3wzM4p?>G=eBiy0`LU0 zvIEvN}nPOcrcm*K+>Pyl;kage7(J&`OTl!V*i5&+y^H>OS*K%;}guef!pOAF8Iv|O zN!~F$pmE=2XMUNQm`=^?-H4{EjKw^4zD|xNYC}Hk>gjpm-8%ioL!4 z(q&6()vk5+%-P<(`=+N~S4ES&W9;zv!^|yTpMSn$`HE=+3=xywTYAc*cWKzs&u}Iz z;@-Q~bY|~mJEKQw|_e)$iboMxNlAzU)<4MuS`eh4IV!0l(F5=kLP?e%-;|>Yw+8v^THIr zHGd>z!zGVi(>Lwh^l=wM^r!DFjif3lo+PhNdFrXBmM>e@r+43T=g;-**=Nm~RprW+ zfB0r*bKY2B(k~1A-VI&U+ufzzv`zkIz0sKWF88_>x#7L&=v1?hhhLYLj)QmXP3Yp| zV^pcpVFy#)13T6^r-3Q@86<@KL>SD zOg)n8XiJpHz2Gi0Gt3`JHk;CUDcmxIcHw^L*dZFcpv9s>ZV~jQ11=ZZL zAuKx8#k3_-)g9K-`Tye`tW9zZ&M&<2Zb$cD1xPzhqF1CTD9uZrHfUenJ-1|jW%{&IA^q9-haR&b;pLh zcXb}UdyAl&t5@&X=bjO*eBSlXtC3^Px{jvEqX{Pe{JD=g^-N@fa(^MW04BYyGH0Y} z)0(`8GLn;13w-&B-o?w^5SC%^YS{4ZEGhL&M7*toW9|<(r6%7pxcIc|t^IlDXh@F{ z5&Pmz{>*<+3`sWSzyJMj|9=6wvz}_zs%bMIs}82zmo^>IC+}Sy*21}^(*Q9ooC4 zmpZj3BxFr$%h@Z(x3E3>>7Wt&j2+*7qgP%1nVlOVE-6QMM`k}Lmc-8cWMyRy9PrAI zF+Y0M^!ohsl|>6m?x-MW^ti4r(I55;Y~EqimRtT^TR7`~^)Agl{iDBh$+1Ppx{kkW z((Z%Vqw6Nj8a1R}zkiYM?=|$EK6_yPn8oNb@RM;t`h(g-Z!g`a{Cl=@x8@2Oqff6P zL26^aHi5%8XyroKFm3K*2tII1itism})s``Wr2@A8%~<`X{6P)!uiyYs*K> zKjZwu2OqrPK~qpHS#>|!8yOEjGBRh*+(nBP*Y>WxY{?R1S$_uoz!Rvfq4Y!75u5vT z?D&FCr|Z?LXPGi(6oBGNY@7n%iD%6?g?k+UkVI1eNTMkKBw5vz5+xLXV$RAASk;tL zB}*v)#haBKu&ybkm?8nios}K1Y9(78AK3vEcUE@5y6}`NVW-zA0L7J^-d5{?HEUWs zsnOf&N|j^=1V|J|+IwGbYwcvks_STfano!u-?AtG#fCPXD9|*eU`IgzpSL^+WQ{ z+4f~ztNH|WXyxZ>pZiee#q~3npRL<}OmG!G4Fve>`wNUyEZ6q0oE@_E)QyThoqBZc z(bTJS+VLfGmv26wr3%S%Ib!k1_dky^DFFY^{`7H*B|d!P`X3E}Zw~5IHE&>|e47Wg z+P`%6`ptjg^@ay|C;*QWfu>wf|IexKV;VPXU~gylyN9xF-aK^VM13Fca!;52mQAcY~5gSI)1)iU95mul_ePbH>~y-yJz-Q>s)g zuj+ret;H0-cYl1jfVR)M+W-19<8{VL)jgfd>2kYq`p}Wn7t-y${DT7Oxa3@pj*3dq z*KB|6X~;=BG=Jfa3y%JsJO17O+3LEp2fmKh)$#N4@@?|0;lh!Nm0$Vbt$xo{vdg@< zZRr;g=4u^#^yt#e)qFPM>ystw_*E@y)o0WLC>VipN^rZ@aq$=8f4Vqz_QJPEyjtd| z(%Qm|jGwijT)J}A)BSJ%2yA)ZjkB^W%Eo_aZ}Z?`gZ(odw6Te!wo(HRhXwOD$6me> z;Oyw?KXSZ}rL>`R-a3^sPDO^sUoi!^IsA6x?5rDEHkBIqH?Lhmrz&(E;?Xk8#*twP z@Ms7$MQ>~S#;}3Y<}A9Hm>e>5o|YAD;gnem(ypghadm!u=m2en`)#YUw=b=|>9c>b zE!x-j{RwtuD>{|c-QB=wv{9z4yC1Ncoqlq6MB??I(~{!PUP#Zf@%V!eUNu%}6;Ne% z=;9BeD_3%Lu2IX!uK`DCfJa5`@Il%` z58Be+$*p{LV)Vt#-!9HfIu*A2c>I686E}ZYz8leBO`5QH+mAo1@@`FnI`-}0VgJWj zvUdyUKY7B49<4p>(@uZ2cJT)@!{Rd)fJZ^>T(Z5r-Pn~fwLL27 zEcf|zcuQtldZvAaI*oz`y)|~)z`CWco;!JsPm%{73CVNGPn9mMO?;-!UYL_}XVmM1 zwQsH1Naq&Nx$94h*RGnEcEZQ6uCvZ`>1_0=lh?|%8W`ZA?clxx3Y)Sx&cjD|9k#=c#kO_Z)e^S&e9@~)%Zy!K$WvT>;9+jDr}bFTIe-ps94=dRZ`Zr(8e zm{Jb4ng%wlab?fl)6ZpV1q4?A{HvK$_WZ5=xDoz6`ev-%yl27G{n{Br_7!Rb_IT0H zfrGTbBT?ecojVEugz!6rlff5ff5M-^{%biocTy6rpE;1A{ka;OnwEG&Tj=0cwo-LR zufMz3YVPh>wS38vB@}=s$fNoU_RSye-M>6C=XQ?eX7(ShptG|nr7IaQpkc@NTPOfe z3i+n|6m{vdZtHGln6q!)e(1`jY)h3k+6`ady-MB63c%w}xGC#Kef{sxe^1=WDcC+s zlq}J#@3Y;e1StTIImxDsZ#?f>LfV}iRR|?Ys!G+J#zzcM03K&zP5GeFXV>DdtD>n` z&0*r9VG6)wOXghi7w_!2mQd87Xs*VkeK|Tz0eEZ)H09En6zv#_5*hB?Q3qBYyK+8F z0eCzKG-c+CtF&{*N=VM_e><~YT%`a!o{DjTSyl+Bxc!o%e{Q-eFr$CI0bsaksN`Qh!Q!axbp{JXVU-l(P{(S#zyH1~x zLw)TMkB5haN1wise{L~2dDU&&A*iv3F@NQ2QG536JaJAtN##(@C$MhDwy>KW-t5=h za9P{Q*}DD51Xt1Ju9p$Da`sA7z~~|Vj=%X?*CO}q*^|5ZO=aWsZ19U+n|hShSyGRz zo)oH{YhZKwXiTc1`P**>d6a&@(<^g1de64qF{hJLEXK-Sf3+HQ2=etX=KWY@`QwN3 zc5=3Qt2$Z7_eZCwinTj*YUOct|N1?r6Ru|)U1|h&?cAy=Q#1S}6s;)-5>$a@XPNVV z1IR|F*M7SBzB`XdXx7J9S>@)H@8i9@^#8EBy|O8DBYNZPRWZf}O*;iR>#oOt7ruS= zbv1fmB3Sa8IqUO?b0F1j1Blt^e`000mGNklk%Q{mD~bc9IkwQ>Sa^aX}f8p&K{Mf1Tq{t!bBmBi*vjZeF{6Tj(k8 zA=7R1{XY=+g@{Zcx{5di4<^$HfayC_2%f{iERvmnubKlFB zYx~3FOE>H_vZq(s+=qO;U9v+~9Y1;1x3c}Y1JPOT!Gm7WTBAMUTf-r1_JU}ue~2>F z?>S;}f2K{@(s}RQs`lrOEI)ld-ID*CO7?CogBy8O)IIF!rSCs@+ES~-TRnUobJz8$ zS5<{9II{mI-=0-(p1j{fE_pkt)jY6n?oL_-`9}VDyi$OFeOH6_uhF4Z^y#p3SF&gd z{t}ARl(dUCtXt8s<<@sw{$@#b{Gz?bet6Kle<}6if0VUs?2Yz!-%wd8XRrQzC3eNA zqrYCE%oS61X4b_k*#_^bY{chH|K$ZMpjt z3}x+gRy@5+XRj)U0JpOD)-7GWhP(ao#GleF71Qtgkjrm(qU?1#rL)O@?Q}Yw_C@x+ zf9C=G$sXbTQ|{|V>d84@uUmA%^x)MBe`gh&N)2D^(%^sRuys0nql>*tF53Ifa%s=f z<(q#p)M-$o#xqU*8u~=Q!2SQ8O{Iqg!EcBJ#f z&-b=6xVsqAj&51I`_vCHr}k{#dFa~jgQ2r8SJvj|vj-!7xSD?X{PE43cb>dn@Tlm@ z`L$|fjNP_m^WmtNvp*aUU%B##f2CSLpmx^g0S^I>k0Q-|YBa5Sd~1yL>zfW9-?gJo zpLS)`GxgecJT+(W)d##OYxujXLMdIn)yO_-$=WS*j&8|4v{|iDzpjB^xd%uM^?URi zQhv|QJ$$Tt2>J5yU@+n)TVV5lG5Iq>?ro!h?n!BDwgBmb7QQa1l?e{N>1(WPtC zm75RF`FOvwul#IKBfqOV3o-Z6p5D+tYUSE33l6H>X|7oZrbVBizgR!{Q7>^r`Xt*8h>!8 zQ`JIEe;&k=Y0;IwHs)=@jS7I2qTZ8f~vN|Y?oYDklpCbUx0ENN%=Y}>Q;{ee=Cj^_%p*wln5B;*Y%S?1>i9!|JkM&qAtzpy7s^Ky-DrwJyqU* zcf$^@J&}{0bHBUQmMm4mMsM?6w|f5!;Ui6fCr61#cPnXa zD9f4}>D?2(IC&Znj2-4Df~%F~sqJ5+D-w>qt=YhRZv6DfBV8o*yb32sjhK#9;7~I@ktGKyU zsp3|@p3mR^-t_6GpH`4o;?A8r3KFhfy|!ZIsy%zYJ#+S~!sDP$9q$&;2lVOPvtq>x z3PNa#0Ienq7A{`1X5CG*S>bVGr`PxD)qU`*gD8s#no=ax(=-QlUA=;r|_ic)wBD=3FB;SZ50ZOrW8_odV1gfgN`3Rq3~q!^=+_n`Qmcr z$|)2IO(_hE#qv_;ZYNGeDLje%{GQ$LQo?wxY$F!GNp-^Z_ zfvsM>cFo!^6n~y*+Dojx&L08s50x*ISK{A z?+_LM=X#2v?+zVSCKm-tiQcu@fYGBmTetF~ zWo$HIU(AUm|H%8dD2zS3cg&bRrBta>zh0<)J8Q;N?IBfUkM5;^BGD9UosNl7kARbt z^CdMPgr-t?Ac|)!eyhIIjMrtJ2mRkzk6_P zr^3FG|3c#861sMIdH%dPzhp)GV&Q_hojbo28yl~R>=C{6Pb!*Xt(0piN?Q5y+`SKGWrjRwqEd+7VPq>FK}`(_Vl>}YH}HgxM?Z#(1jlXsuq@@nn> zbtP}G^ZmBTqo;-*i-|qBcJj-O+74N;>sU3=bgOO5TgzCh^Y{h34#vc43qK#5 zJAc?Q)*BNR6RYj&?h5(ej;e1|M$gt`az|BhN#F0^IHPY9r@ZH>cL^9WJM^Ho+x;80 zAQmlb!(JPC;6TJb+669Oy2vJv_C-)&TW#TM!(UfL_9$NZCm2n!R$@|;67_W1vYY?) zrF+$?lS8I0UADrSP4rH+nt19HzgZa?b${>1apu%rZ#A#>Opi5jc~{NYdA4oi7`JBX z!87ALd(B)ip~1@bk$23xhXOEt^F4nbuTel)bB|AI2ci#cKzS-Tf<@3vzEn2x^iS|X<&RyCgZH(4x}UltmT25rhyt(xbQsnh4pUuf;V^!l=T zvneIzzGE;vG5`Pu2}wjjR5xq6nG`y4+=Tt9${6p~ctvmn4=4S}@QE)sn5b=Q@T~0= zn`E-9?crv$eb7KXw|}&!YAjoO*w&NU zv@7Z2$&CX%?Tju?4o0J1nUa$8cVRZACYiXS)sus!SW7#YstRTAzHg_`__R%%R!bJm z*Ro<~XSZ(cDxbQ~%=&cBoVoK0v@3JUkKr+9w>EwMcfLmN*mlPLr0>`EtbL!`2st)? zb9{Htn6Yi@czQQ|>3_{{;%=%UF{i|(nDrIB?{-Z~jGNJ%q9w+hawzmjifg0b=B=AJ zrG#%hl4Mrekjiv3ZfTc#u2nqm{;BcuvhOdOOv>o$V$9ox-stFREb5VB?HG#opH~~C zX#cgfKiU_I7tPZaDhY*t>5C`kKGv$>erhy zcixPdvsFQv<999FK5%vS_ttN3@~?H_u}1HJ*1-X;v1`Wdin(R1|Hs?pjRA}moE%NH z+YfEyX{V3NTdIu>^wpX)3y3j?Mkhm~B#i_}F?w%Hd7T!%Kk|`dw)&Z8fSdI=Q|T83=Z}*g--6* zbK-&f9z{$&JTGKZQvG+0wD~gzH!y7<^V-qo;oc^&y(*f~?~ZzH*kA?g&=hOM#>NG-Xn&(n{DklOMms~NU>)W@*3$m3(*Dv@ zC}y-5ouCv5O(}rq1DY!oGl6XxoE8jop8~jeG4c79ZMe@{@uAn-9yt`@=;)weHO46g zP^F67fd2gyiVf}MCMX5PIHh1rri?~S{+^MMq41IMhkqCp~FK|1E#|q<=f-xEm zpMQM(p+Ye*HRNN0QV2Aqz+UVaJaovb3QsicB@PZQ>V+-E6LX)!$jQm++@<@Gqmc?v zBJBju6@T#mo6zhSt?hqymxn)Hpx{e6iQr3+UbCZ1ABY* z?5^+x>Gsbq2mif~pcDn;l%iU_dhPq;CV%j`KaV?YrV}#xqwd|hDijS(5fB#_zi`o# ze{K5e*LQNmzkv&F9~bua_C0(2bI>aT zv|}g=LTHM-?^)k}{3ED-Js$;WGzA5K xB+mPj!4^1^O&kXzuLfR200006Nkl@0O!hdCEq@DKly$!N@gF$DFz5`cjEW#8tO}+=N~qHTEay;IP zTrWp0)6Ffry0uoWt#-=tmS)zfX9=~$PT)}&YY|Z)FQ6V!5xfnsfPpY0o(I%6dx;M6 zbrt!%U%q%_99e{n0U9%{09sj`S<|VeXeVVAfX1z&(y0zO zb=~KZbA1AEAAfVQ1I{(&5={XbyDGt^0DzOXozN5jl4uG5Np-vH_72F4pBUPuOSkL( z449j2Q+G*4<|mT|44AYoyFvk|JF#)f>C>mTZTp`gLk62nraEn?3D ziY3UR?h=C~ATT6E`I`+&*^*}c>uV)bK7Vh7uR#HDhJRR7)~s2(diCn~jT=|4SYFqx zxLJZi!ou#6&A+=8sd8lJtecLI=IDt$Ma(OOKyFedK`9?%l(OO`N?vDd);s z6`5qFKw|nm8!5OYaHVlqWBK(qjXrC*`EhAN2pG zClnOyn)j<7p%de4c2$v={K?#j!@|3F34LtXgxA)k+OB?#itH_KX|Gp*)ZUMee_?%Q zi2@oYv8EV}#^o!Pb$+mOR%X`NvEz%13RV5&ZGV|N?Zt$Oh-Vgjyz=9Bo-!rRdtrHc ziNUvLbeJhKA^F--TavkPTfUDq`e92&&bk*Szm{AX7W?}J3!WKh$yq*Y(tKx5%#`LQ zCzTF-W7Cc=e;@VZV969V=ga?D9OdQJ=cWJNo3=FC-=Io%zdvc(yXoeEQ{Gto`>9q_ z>VLbFX053SQ})LB^VjDGj9KuPt$%rAgel`sFDy@`DrlS}ulqD@+H~cL730Q^KXUZQ z=+Wao{d9Hn<}Gf$Sw%(7SYY0dFN8h6>7%j!z60O+i?zaFHrE{Gft9K2U&zR?RfGhD zjfk*J+LDwrA|%ADY#9l=Z2pnaLFSV5C4cKOea5`^_Dt<%jWx`ta@72F$+2P4w>UXt zFj>b;jt=p?@>6MTtJ(I%Z>x-WW95Q@HETxn2~tn~X3NIR5kWzvc{b%^9WmS*WL7~> zEeMG$b+eci&=?6{OE#NLt5&TTKmI8#D^sUV|Ln6*Z@taW&(XS>1AJ;aVo>?J;(ynt z=H%J3(^Ge46}Saod5^%Te*W>v8#7}=dRAs^NiGc<6YX!V$ji*InS*-zUptny6TM;n zYR#UU(pxpf&1ACN`UFr(UTQ{8e$MR3gxNoN)nv)5R3SklVy)X=pFU)FP*6ZnnAO^^ zpOurkXr%7Q{V6Uk)i>Ogm6bWU{(p_m(UznypE2$IoPek(YtJZa)Dscw=e~P&9%Bv~ z7!?%1J|V;ATN0nDf~Q9K{4rRug-Q>#E_S?CZV5Cge5;Ye$6Mz z1r`3+78?Am(ZS2-uUMlD);Ghh30k`q2nFRk>w^=Hr~MTXMM%sgA3_HlfDqo&4goqsle#tXJ7VJ#yh??_KjBm#46C(Q#o*iFljp^rO_{mXPefh_bpKs|G9c=k=lYuWU zniHJ!?(E4ECd_y-$$vCEEo+gi5t|K=0iH?3k zuh)+mGrC!`W(q)KB{oh0aNqlTX?=C4gJZXGgg)bLS#0zC@S zmc6$!?r6EXP4?5tOJ}_J$-WW=;GgWSk5lZ~+c&H`Y>J#YaajABfr$$38`Xcu@+f+}4?8QgZHYe`dfAoBb zLzz4DiiwE~Ywg&#YSEIt7qrDo=FTy88~K0w(;@m(e_OY1$Ns#ca)-NDhmc`0gTh)f zkpk|BCa=Ews;ZwazfRit)i;L@A9=WYuwMUc%sM{XpIW*>RWX>W8c0ULs0KunyP_({MCM!u3_yxoq9$+fVvSFrwkc5;D57EWFJ5I_M#7GP5(`^rXJdhrKK0N zpyZ!9`%C`^{(oddts9q@+m+62)ZKr4*kJ57SR0#Iw3T`UcrE!L?%3(#a34!s>x@@I z>>ehMnsvNO(^EHPpD79V^SXXxM|p9%&bx=T?}M%Ms#VP7fQWLPg<%SCX9zUK;O6%1 z)Yx|xe{?c8Z~nsfwXA3_zV+^sg2JM9ZGE1Z@|3nhZQJUNMi1?vFL&6rzt?~I4(^_< zTYBoRZeTX+R7v^O2dp}Z_J5O{TX>=1RCd;hqH^n`EHvaemZxnY0#iZi~L_v+HkhcBoWevd>&j~^Uhym6L{e&J94;nnG* z1_T%j4t}wA>B|cfvda{JJ3;JPveD>1Yx+d*)-7#XPX1o(-Kyox|A^IIyv1f7ZNhrA zDf@o&y0oHy3^D9QshhvpoRWXjQB-+u#t~cVK2s(Sj~URr`-1`AdV7B@Plvac6%>^j zTXpRfHSxK*@5BasoISGt98Z!5?g+_i$xS^xw299Dtxr7wioQ3--~M8HdeB`d#ZmUs}^p^Zrf+n;NE_YlS$jNOY}b8CI#S55WYXfY-%=d?yNd( zqV%o$j-TOIb02No+FcH%^Xd>fdRpY8ZH+hI%&-6On8FQl>p$3|lvn3Ik$pOyNxXXb z*|T$aWXDgwSol`r1A|_gZXGqgbZuPXlDBtgi;S&0MUEO8=EV<5THuawxqSJu0stZW z4k44l7iNFb9c)Tf)#dX!g_%2Zv_EH$*$Q%twHLkoJiR+w0{_Lob6+ntI&Qdv-d)#3?;8G8&*&HXDFF8i`KBD-m;aw5Kf733;i$ZH z%axnBHSsXJPh0oz?Sk7V01cmTQ$Cyh)n7l^d%1tAZu@j`b$M+3!z15{QUDq_$)>#0 z`~7n{1(&PTZE$f_-W^-Mk~~=fXvoBx@^Y{LJeOUl>Zf%Fuh+htssJ=tu1dn^zuj^! zr@leaoc-T|KhH@}02(ZTrsQXy*Uq6Rk>So|wR6>;Ge-*)fQCt+DGP_L)~*>VAyte>2D=M#LVbh0~{w8*4v!A|XKWoc9a4P-JDLdoO*0$M&!m|H* z<(q$fZGgH5*iUU=@Lr1dxakufG%5hMQm=7JRn_Icu1?cN7S3;?Z*2*PcxdRdaj_Fy z*VeO3_Nu?G+H;3)#shFi)oYw`KBv&RTTy>5?4S4bcSjxiR+divLLM1t(JS{Z)BAKu z*qEMsN@kgrn=Y8B;>tbJMmbY|l7&qR4J)85n=u zXK+;S0P~fV&+SW0+`9LOc9F`fLr7$B>E?usgJ(|YYdWp%BrEvI-wkP}uU@Zo->OBc zO2X$%wpy-xuXCx1iHX&lUsSr54-Xj{(s&XaQ<8yBjaa`DXf z*jga7~xA4x<(RO#$_DeE_VwywpDiNWqQ z3yB-dX@rzD5W=*&H=~b{=LOs9r}!jo#B61o)x$D%gvk8yG+*p1r>1D)vGC|kDjm7`pVGM|KIu! z>Q{5t_Tq)y+>19^_e_>eIZNl&t^eRqpXy`Tes0J1Y>%E}W{eK>tX>q-#n&-^b=v;3 zp>2#uc4n0O51IJ1)*5X|XeY1oMN2ZAeiLQtK6?5e%5NUy-w`dfXxq8+;q+B*+^Zd9nHdfkwR+N=3XQg$2<9o_!o{@ND#*6gHn-^k$V zo%D|iO+B3E9d7O3)};Mw4DO$CFyY9Va+-pBLcN+&aI)CB6)h1PpO3ii6-V|*NqY|5 zY~Ex$`FG_kozZN(dO&}bpU*mb;mom*XYc;$3eC=xILgXTo^hDEv~Pax&@=gUYiB%s zQPoA+v9zS4r;AIAa*rN4RjRx?I_y_Jg2~gUcjDvaXPs4E;eMXi*7a!7$={fkd%Vcr zx~SGgzSr+W8TERl*IoH#1tIvNvI#F`-)o%ZrRdn7x zhmPp+k89X^z0vGzRC)C~?(F%A%U8x7H+AjNsZ+N;VLd}qbD*OI;3c{;bE zcHQSMOP>B}=boA~DW2~3lYcL(xWzUtA84*_gR!|^3!UBJ?)P9|*P1_pt&MhN(#~<{ z&279?X}0#i{i}bgmgXKlS$^dQy-u%pl$Vs3U0Gr;JyUSeetm)Lh1sdcRo4+uPKk-? z9~#)TZ8PPl^QTirpTO3t;An2~wVjt09?2;;`Dvr4n=Dd5gHf-hwCU8+xfN@!`^2Yx z`Ocm*)ju{1o8Bv+)h)L13377h*VFs-9n#Bq_=^ud{o8+mv!%A9fBSgJ(ht@p=awmb z>n;(UjX9e>|1>%KbV2?;?M&z13)i+X`TLp*cE_*%CjG#n^u)NW-<|t;F!aXep1KQJ zyOIx_EjoQPEiP{B{=&LPMc+KEe=qZ~&CBBcw(n5Zfwb+bR;AcGghy&uZEms%XgKOM z_vzH9ecFGXvMcTDEnUYAY^J(BG$=ZK@zS$5IVd|>{nc&p=-7Y8IJInT{Nmm5 z)u%Q)^qLS88CZRS)YN^{gvl)uwT?-`JsyZ61#56?T7i>ut<^w2wDsoLaRue#tIXJx%K| zbjpY;CuM72#JLhQ9QFFHgLByhuRr#oqoTG`tv}wIZ%cY&#fIwpQeiC4Q{C|UNRZ=ES7Exi!-o_qIJF zCk*TD=b->d5%`|B*_{`Z+RBt1bIYc0A9{aI0cd~(zCUHghA}R3PwCMHDbL1@RR9_$ zfu^+W+Pcq}9!exDF6yyyp>1yODl&k3fXw&Nj{f5y?~WE#Z$3e-M!r2z0cfx!pT+TN zpAXI+v)%TyID7(Iy|ilCE(d|NbwkdEwxKqzy|NQxcU2FGNIjU;=wp!OFE;@tm(UA}R z+k75r3fwNYkRgGFZ%wQqsLNCo;g}jQ1g8#UhRJ2-O;P#BM)@#-%V|p`S;P&nv8cjtN7e(D>X+cHkJ$u0ny~jeK z*UQQ(JfO%1NC7!#V&&6UtL-XT?nYIA^d93WF-ERcv35Hj)&Pm%hBj;M&O_Cw(6Pp+ z)_N(`(07f-GJ2Cfd0v@??L_p}uG}9dgICWAW-M;fw!q&sW973Yy)?`_rpzx0YCXeH zD){>lb$Lk{?KKsUU2Y~{8Td|E+|J|Bc9F`y$%gP`#4&;_zmE`liGJ&XCS1;qp_kUOqfhb|HENb-d(BFQa&Sf&24o z^M@A>6Ua1kS=Ae>LSSpL$KQRoj~43{Kf*JJ7R2!n8$q16@d7>Mjzr-5iDl=ZX{I)L z?R98va=Ul6nUU~!mG%&}-WboWzS35Q4L2WduqIb{9C7)?FGrGh7~GXM?jo~q^F;di zJbP5y`+Ze%>vtCI8$@dRYCM{Px!X4^(BR;q#XLMK7u#|wSGuC#(>AZG&OADtz2D<} z0z&r{Lkn~sYAO=bP1K3U#;ROxx}NQ2#6>ZEhSgSeF?cRomK>&q~wyo`E+P%G3{G&+ERi?(s|6#NY<>r94=v@E=KDifVzB6h?BHncT)#Xqw)_og@;2dH&@w?|yM7rDw=A`E zTmB7@-6u24?nv{(!R6C3yK#X8xwC%aSMeq-L|px0%7z@Y8zBp~Ak)I1d7)nm_peW1 zFTx$>tsdIcoV`YaMzzXq-wHf&D(K`@)z-hr_Ix-AkfCj|h)CK9GNm6`vJcQpB)nN@dDDo6MAc6 z5ce^$)rF^`Rxv}oO$KAty ztAQL2k-TG{>GjnEqn8=cyiBu>M#oJ&NLpGtOxL2fVNMcfbT9102 zXN_NGjlaX2e^5-{d@1~H6Cg00r8tH?8>&=t-@`C6TJF!2auNxm;N9x)k0i8y=@-B^ z_*k`F6T`gMBEx^N39d`H+wSoapv*)`?{EwgtTpLgS2ASUnCYpE*2{W> z>OXUM>=ZPoKD~4{K9qvXS^Ry)C5?juqt0#|8`(*H%JuZH*1obG;Wu-e#5d&jBSNR;OAT4}Tt&B9qC@3t~Pm@|=JU@T>9^I*>q@@DG zbRNf{Psk70ML(inY^zh7R4S>cZj*Zx5~MBLiWR4LyjiGjJ}(N#jRy4J?E3n7*@nb( zGauS&nXm1ngL_h*Ct-hl4CR=A(t9(f|3&Dgnv7XXe(pXcg;gPuBDQPm60e~!;>c5( zR$X+P&91JQZg*I_sK&@X@bmRR-+;D!QGR{Ej1E`zi_eijm`vgjR!w=rbTa}e6RY6q zTO#p<`J#8QL1oFE5_SE!s97vu8aQqyiU?5bFb4fd^4f;>1AIivuU>3^oZ{1+ zQ**W*WME2)_k`IwZ~i_&+djKV;VUy}RuBrNq!>+InEPdi8k$nPM&RNq&2syL(jFm& zjae{4%1SP2eNg3!m=mEf}OQjr+1P2 z#9rgb?)2R1uNmWC(k|ZV&HV=O5R>T+=P}Y+gyEuNYHe;zFmuc6H@SDf^890um-z)m z2@kl!x?(M$YQL&2c_cW*pXEq9@u;!((T2%+$=%-rx0SyGTzMz38ogSqzg!tV%JJUHHv+meI|y%W-0juvOctZ=fYp_B=A8WIlASI1)<26&6>dCrPloMZXZ4P~{cJuElFxOD$shaeztWE> zt{s&UuwCndc(1`cd7*`e7czv4I9ZAnECkmQJ(wQ+_hr>J0onwzu`z+QpBj*$Tm#Hz zWz4Lt@6B9j=4(DGu~m->h-lAvo~6)>M@Ge8z(s2}nVN@;>Jn3Z5dEXkIGTUe=;wCw zv@zA%H^0&Hm3azEt3@Rl04V4QeuXh(}->OlU7lBCj9iB-Ci# ze829sraAt2NyFIF-(R$YGxzy@=Hk{Cg%rG_VVM5Bu<_EE7R$Qi#7k7E;!Sy!d>~ya zvAN_>&8C6uj-hU)exOQ)HI!bCca&CmUI(Xp_ZV;GqD@q-#c*lMRdJ;J&~GC|u@sfR zNd14P>dTK@59x|GMHEP@QrlP9PB-rXD$eQrt8V) zWM?4RT0$nRx*+DD=KYCJ$hvQ~WF(|_)_Q8TQ8p(v!qw*PvQsQ_x!lKc zICXonM#BE$xsTD)%hK&)vRFuyWn{Ixa3qULNh^fQ zb6c&gX!`Pn6nY}BZYZIKRvABYFlD(ZnEh$;)~roF#Z!CHthRu+GSxBo`?tGI)I&E> zIIGUM#gnn}0RfMUJ?_QXFe>!;T8M8dn10eA^L2e( zh-ZtX4i)jMC`KpP3 zS~TOS%@9L!Vdwa`8zx%A#Tde-=S=Q@Gjj!40dQ_YiR|-%%?k&a;w7_2)S^LO{?P*d zK_KZSmz#5d?y_$n%V`(!W8*umtTU#C3^$T8n!LURlQ3lq0e_og^Dp|91LiQJ%cqwQ zsZU=MfA+4W=wTw|sZcAgJSkZS1cGRjIULV9_{i$2*cDXK5yi$e9TaHPWthZ#4LcKyq|LgUUZ}ZHa24riI zZQHELR>5HjQx1bBEao$CjgrXVXHO}X@jlgR_Bg#`T6J6lA92CWsD)!@%Yp9(qRL5_ zXw2hh%<}k z8|K}tNsVKQy~N1kn^k!w*WuCPm-{VXh5DosN)~-cZ+;s2CCdH`k+)W6zCEQq z*Y+pphYjXvkJqOTRbw8rzN4t-;4{caty%UGUs`_AZNqn)~qEOw`jcc!m>uEf+lgI9;EKTmacw#5>i`SmP{BXyIgQw^F z{^|mLL7RLlT7xMM1Eff5#ys>8q)c^k62Jta{C+bX7q51(PW%sx{I(C5yimSt5XZmZ zuJK0ZmRw0+Bsd~{!(jDzYM)9>XQ7Q#&?i~1Eb^OIrp|1$}5~=e+cnaG2O-bH-r{H=EJflQa&0BgF zV#9}&=tPU`_UHE&v8#fFbe1stn(j=BfZu2{WIiAmKs4PyX8HfMN&9;%@Ne&QKG5w8 ziZ3Kj|2EL(`5csLV`_0qh);3exYLL|iz8;U0Ni7H9JO;sg7OVs6`GLIqs7Q$uUJf0 zx7gsbTgo@{tAK;(O4dn4b1vy>Pg2HW!#0Rlt;OBatTuX}@3v}G4N zUViRaAh3;3%yBrbVG5MZMZMSbAZKIe=h~7;RFDpkfl@3CxYI12&SMuXT>TwJmdvFB86=M*RbVzj@=6guX*YG)kPB4prFt&D?Eu-{SJYBp_+bzdRB+w=%$=9Ootd z%Y_gPA8axTt( zugc|`4!1>9fwz|(I10uDKci1JjomHZ%3uv>!|KTUd>rWr50Def$zVA-m{cv8≪n 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 literal 15370 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 UZB4=k^$I{ { case "MatrixActions.Room.tags": { const room = payload.room; this.addRoomAndEmit(room); + this.emit(ROOM_TAGGED_EVENT); break; } @@ -493,6 +497,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.emit(SECTION_CREATED_EVENT, tag); } + /** + * Returns the ordered section tags. + */ + public get orderedSectionTags(): string[] { + return this.sortedTags; + } + /** * Load the custom sections from the settings store and update the sorted tags. */ diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts index 4945491890..389ec56a91 100644 --- a/apps/web/src/stores/room-list-v3/section.ts +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -12,6 +12,20 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio type Tag = string; +/** + * Prefix for custom section tags. + */ +export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section."; + +/** + * Checks if a given tag is a custom section tag. + * @param tag - The tag to check. + * @returns True if the tag is a custom section tag, false otherwise. + */ +export function isCustomSectionTag(tag: string): boolean { + return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX); +} + /** * Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user. */ @@ -41,7 +55,7 @@ export async function createSection(): Promise { const [shouldCreateSection, sectionName] = await modal.finished; if (!shouldCreateSection || !sectionName) return undefined; - const tag = `element.io.section.${window.crypto.randomUUID()}`; + const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`; const newSection: CustomSection = { tag, name: sectionName }; // Save the new section data diff --git a/apps/web/src/utils/room/tagRoom.ts b/apps/web/src/utils/room/tagRoom.ts index ae9b52a174..62bac2ffca 100644 --- a/apps/web/src/utils/room/tagRoom.ts +++ b/apps/web/src/utils/room/tagRoom.ts @@ -13,20 +13,29 @@ import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/ta import RoomListActions from "../../actions/RoomListActions"; import dis from "../../dispatcher/dispatcher"; import { getTagsForRoom } from "./getTagsForRoom"; +import { isCustomSectionTag } from "../../stores/room-list-v3/section"; /** - * Toggle tag for a given room + * Toggle tag for a given room. + * A room can only be in one section: either a custom section, Favourite, or LowPriority. + * Applying any of these will atomically replace the current section tag. * @param room The room to tag * @param tagId The tag to invert */ export function tagRoom(room: Room, tagId: TagID): void { - if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) { - const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite; - const isApplied = getTagsForRoom(room).includes(tagId); - const removeTag = isApplied ? tagId : inverseTag; - const addTag = isApplied ? null : tagId; - dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag)); - } else { + if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) { logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`); + return; } + + // Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists + const currentSectionTag = + getTagsForRoom(room).find( + (t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t), + ) ?? null; + + const isApplied = currentSectionTag === tagId; + const removeTag = currentSectionTag; + const addTag = isApplied ? null : tagId; + dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag)); } diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index f40b60eebd..a87e69f0fb 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -10,6 +10,7 @@ import { RoomNotifState, type RoomListItemViewSnapshot, type RoomListItemViewActions, + type Section, } from "@element-hq/web-shared-components"; import { RoomEvent } from "matrix-js-sdk/src/matrix"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; @@ -37,7 +38,8 @@ import { Action } from "../../dispatcher/actions"; import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import PosthogTrackers from "../../PosthogTrackers"; import { type Call, CallEvent } from "../../models/Call"; -import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3"; +import { _t } from "../../languageHandler"; interface RoomItemProps { room: Room; @@ -96,6 +98,13 @@ export class RoomListItemViewModel this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged); this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged); + const orderSectionsRef = SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () => + this.onOrderedCustomSectionsChange(), + ); + this.disposables.track(() => { + SettingsStore.unwatchSetting(orderSectionsRef); + }); + // Load message preview asynchronously (sync data is already complete) void this.loadAndSetMessagePreview(); } @@ -181,6 +190,7 @@ export class RoomListItemViewModel this.snapshot.merge({ ...newItem, notification: keepIfSame(this.snapshot.current.notification, newItem.notification), + sections: keepIfSame(this.snapshot.current.sections, newItem.sections), // Preserve message preview - it's managed separately by loadAndSetMessagePreview messagePreview: this.snapshot.current.messagePreview, }); @@ -279,6 +289,9 @@ export class RoomListItemViewModel const canMoveToSection = SettingsStore.getValue("feature_room_list_sections"); + // Build sections list for the "Move to section" submenu + const sections: Section[] = canMoveToSection ? RoomListItemViewModel.buildSections(roomTags) : []; + return { id: room.roomId, room, @@ -307,6 +320,7 @@ export class RoomListItemViewModel canMarkAsUnread, roomNotifState, canMoveToSection, + sections, }; } @@ -389,4 +403,42 @@ export class RoomListItemViewModel public onCreateSection = (): void => { RoomListStoreV3.instance.createSection(); }; + + public onToggleSection = (tag: string): void => { + tagRoom(this.props.room, tag); + }; + + private onOrderedCustomSectionsChange = (): void => { + // Rebuild sections list to reflect new order + const sections = RoomListItemViewModel.buildSections(this.props.room.tags); + this.snapshot.merge({ sections: keepIfSame(this.snapshot.current.sections, sections) }); + }; + + /** + * Build the list of available sections for the "Move to section" submenu. + * Order follows the canonical section order from RoomListStoreV3. + */ + private static buildSections(roomTags: Room["tags"]): Section[] { + const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {}; + + return ( + RoomListStoreV3.instance.orderedSectionTags + // Exclude the Chats section because the user toggle the other sections to move rooms in and out of the Chats section. + .filter((tag) => tag !== CHATS_TAG) + .map((tag) => ({ + tag, + name: RoomListItemViewModel.getSectionName(tag, customSectionData), + isSelected: Boolean(roomTags[tag]), + })) + ); + } + + /** + * Get the display name for a section based on its tag. + */ + private static getSectionName(tag: string, customSectionData: Record): string { + if (tag === DefaultTagID.Favourite) return _t("room_list|section|favourites"); + if (tag === DefaultTagID.LowPriority) return _t("room_list|section|low_priority"); + return customSectionData[tag]?.name || tag; + } } diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index eeac64a3c2..e711e340b0 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -13,6 +13,7 @@ import { type RoomListViewState, type RoomListSection, _t, + type ToastType, } from "@element-hq/web-shared-components"; import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; @@ -156,6 +157,13 @@ export class RoomListViewModel this.onSectionCreated as (...args: unknown[]) => void, ); + // Subscribe to room tagging + this.disposables.trackListener( + RoomListStoreV3.instance, + RoomListStoreV3Event.RoomTagged as any, + this.onRoomTagged, + ); + // Subscribe to active room changes to update selected room const dispatcherRef = dispatcher.register(this.onDispatch); this.disposables.track(() => { @@ -595,15 +603,11 @@ export class RoomListViewModel public onSectionCreated = (tag: string): void => { this.updateRoomListData(false, null, tag); + this.showToast("section_created"); + }; - clearTimeout(this.toastRef); - this.snapshot.merge({ - toast: "section_created", - }); - // Automatically close the toast after 15 seconds - this.toastRef = setTimeout(() => { - this.closeToast(); - }, 15 * 1000); + public onRoomTagged = (): void => { + this.showToast("chat_moved"); }; public closeToast: () => void = () => { @@ -612,6 +616,15 @@ export class RoomListViewModel toast: undefined, }); }; + + private showToast(toast: ToastType): void { + clearTimeout(this.toastRef); + this.snapshot.merge({ toast }); + // Automatically close the toast after 15 seconds + this.toastRef = setTimeout(() => { + this.closeToast(); + }, 15 * 1000); + } } /** diff --git a/apps/web/test/unit-tests/utils/room/tagRoom-test.ts b/apps/web/test/unit-tests/utils/room/tagRoom-test.ts index 20e5931a87..cec9b805a2 100644 --- a/apps/web/test/unit-tests/utils/room/tagRoom-test.ts +++ b/apps/web/test/unit-tests/utils/room/tagRoom-test.ts @@ -11,6 +11,7 @@ import { Room } from "matrix-js-sdk/src/matrix"; import RoomListActions from "../../../../src/actions/RoomListActions"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; +import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section"; import { tagRoom } from "../../../../src/utils/room/tagRoom"; import { getMockClientWithEventEmitter } from "../../../test-utils"; import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom"; @@ -18,6 +19,7 @@ import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom" describe("tagRoom()", () => { const userId = "@alice:server.org"; const roomId = "!room:server.org"; + const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`; const makeRoom = (tags: TagID[] = []): Room => { const client = getMockClientWithEventEmitter({ @@ -59,7 +61,7 @@ describe("tagRoom()", () => { expect(RoomListActions.tagRoom).toHaveBeenCalledWith( room.client, room, - DefaultTagID.LowPriority, // remove + null, // remove DefaultTagID.Favourite, // add ); }); @@ -73,10 +75,24 @@ describe("tagRoom()", () => { expect(RoomListActions.tagRoom).toHaveBeenCalledWith( room.client, room, - DefaultTagID.Favourite, // remove + null, // remove DefaultTagID.LowPriority, // add ); }); + + it("should tag a room with a custom section", () => { + const room = makeRoom(); + + tagRoom(room, customTag); + + expect(defaultDispatcher.dispatch).toHaveBeenCalled(); + expect(RoomListActions.tagRoom).toHaveBeenCalledWith( + room.client, + room, + null, // remove + customTag, // add + ); + }); }); describe("when a room is tagged as favourite", () => { @@ -137,4 +153,26 @@ describe("tagRoom()", () => { ); }); }); + + describe("when a room is tagged with a custom section", () => { + const otherCustomTag = `${CUSTOM_SECTION_TAG_PREFIX}other-section`; + + it.each([ + { label: "untag the custom section", applyTag: customTag, expectedAdd: null }, + { label: "replace with favourite", applyTag: DefaultTagID.Favourite, expectedAdd: DefaultTagID.Favourite }, + { label: "replace with another custom section", applyTag: otherCustomTag, expectedAdd: otherCustomTag }, + ])("should $label", ({ applyTag, expectedAdd }) => { + const room = makeRoom([customTag]); + + tagRoom(room, applyTag); + + expect(defaultDispatcher.dispatch).toHaveBeenCalled(); + expect(RoomListActions.tagRoom).toHaveBeenCalledWith( + room.client, + room, + customTag, // remove + expectedAdd, // add + ); + }); + }); }); diff --git a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx index 294476d075..35ee53efdb 100644 --- a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx @@ -21,7 +21,7 @@ import { RoomNotificationState } from "../../../src/stores/notifications/RoomNot import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore"; import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview"; -import SettingsStore from "../../../src/settings/SettingsStore"; +import SettingsStore, { type CallbackFn } from "../../../src/settings/SettingsStore"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; import dispatcher from "../../../src/dispatcher/dispatcher"; @@ -29,7 +29,8 @@ import { Action } from "../../../src/dispatcher/actions"; import { CallStore } from "../../../src/stores/CallStore"; import { CallEvent, type Call } from "../../../src/models/Call"; import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel"; -import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import * as tagRoomModule from "../../../src/utils/room/tagRoom"; jest.mock("../../../src/viewmodels/room-list/utils", () => ({ hasAccessToOptionsMenu: jest.fn().mockReturnValue(true), @@ -83,6 +84,7 @@ describe("RoomListItemViewModel", () => { jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null); jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null); + jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([]); }); afterEach(() => { @@ -213,8 +215,8 @@ describe("RoomListItemViewModel", () => { let watchCallback: any; jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview); - jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_setting, _room, callback) => { - watchCallback = callback; + jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => { + if (setting === "RoomList.showMessagePreview") watchCallback = callback; return "watcher-id"; }); jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({ @@ -595,6 +597,93 @@ describe("RoomListItemViewModel", () => { viewModel.onCreateSection(); expect(createSectionSpy).toHaveBeenCalled(); }); + + it("should call tagRoom when onToggleSection is called", () => { + const tagRoomSpy = jest.spyOn(tagRoomModule, "tagRoom").mockImplementation(() => {}); + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + viewModel.onToggleSection(DefaultTagID.Favourite); + + expect(tagRoomSpy).toHaveBeenCalledWith(room, DefaultTagID.Favourite); + }); + }); + + describe("Sections", () => { + const customTag = "element.io.section.custom1"; + + beforeEach(() => { + jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([ + DefaultTagID.Favourite, + customTag, + CHATS_TAG, + DefaultTagID.LowPriority, + ]); + }); + + it("should include sections from orderedSectionTags excluding CHATS_TAG", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "feature_room_list_sections") return true; + return false; + }); + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + const sections = viewModel.getSnapshot().sections; + expect(sections.map((s) => s.tag)).toEqual([DefaultTagID.Favourite, customTag, DefaultTagID.LowPriority]); + }); + + it("should mark the room current section as selected", () => { + room.tags = { [DefaultTagID.Favourite]: { order: 0 } }; + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "feature_room_list_sections") return true; + return false; + }); + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + const sections = viewModel.getSnapshot().sections; + expect(sections.find((s) => s.tag === DefaultTagID.Favourite)?.isSelected).toBe(true); + expect(sections.find((s) => s.tag === DefaultTagID.LowPriority)?.isSelected).toBe(false); + }); + + it("should use custom section name from CustomSectionData", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "feature_room_list_sections") return true; + if (setting === "RoomList.CustomSectionData") + return { [customTag]: { name: "My Custom Section", tag: customTag } }; + return false; + }); + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + const section = viewModel.getSnapshot().sections.find((s) => s.tag === customTag); + expect(section?.name).toBe("My Custom Section"); + }); + + it("should update sections when OrderedCustomSections setting changes", () => { + let watchCallback: CallbackFn = () => {}; + jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => { + if (setting === "RoomList.OrderedCustomSections") watchCallback = callback; + return "watcher-id"; + }); + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "feature_room_list_sections") return true; + return false; + }); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + expect(viewModel.getSnapshot().sections).toHaveLength(3); // Favourite, custom, LowPriority + + // Simulate reordering: custom section removed + jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([ + DefaultTagID.Favourite, + CHATS_TAG, + DefaultTagID.LowPriority, + ]); + watchCallback("RoomList.OrderedCustomSections", null, null as any, null, null); + + expect(viewModel.getSnapshot().sections.map((s) => s.tag)).toEqual([ + DefaultTagID.Favourite, + DefaultTagID.LowPriority, + ]); + }); }); describe("Cleanup", () => { diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index 4ae01433ce..c0eacbdf35 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -619,6 +619,12 @@ describe("RoomListViewModel", () => { expect(viewModel.getSnapshot().toast).toBe("section_created"); }); + it("should show toast when RoomTagged event fires", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + RoomListStoreV3.instance.emit(RoomListStoreV3Event.RoomTagged); + expect(viewModel.getSnapshot().toast).toBe("chat_moved"); + }); + it("should clear toast when closeToast is called", () => { viewModel = new RoomListViewModel({ client: matrixClient }); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx/chat-moved-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx/chat-moved-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..0d4111e53b6ad782c8b3dc23b24ba831fa81ec24 GIT binary patch literal 6035 zcmeI0=~q+N8paP&iX+%s#R)=ft1S-PD?@|;iBhB#K@<@cFa<@CSs=lX38jjNNUcgh zW}}9|fQS$XLqJrJROTrm5T-C>0z&5Trhmfir@PkW%e&6n`vygP0AQ2HFF&6K0K*mlSktq1jeewO>w{?k*bI35eDutXJpS0VY(GhG`?Blq?K#2b z+lWQ{&!V~j!|3Ff2cp0D#_X39Q3VI?Wjyy#eSea8^SMV^)yq>`?6yDJPE7MS6S&2Q zP_ou)&#k(uw6#AL-P({lA#Qo=;zF)c_gE&*uH>ku#^bx3ZQ^w?%`#oyi*U7k*~r#! z`eVj^0QeeY1pvw9y+sCqrRD#4@bR-f&Ks_TzmDxK^D7^Ys>Suc^fIb1D=W)StT+UU z8m$#Dhye%$!T{)*v8zqSNf~n%Xm#2To>^NPD}}j-$)1EDnOfd19U(^O-}&9HAo*fd z2xs7$Y13oFWhQN3 z;UPK9*?ul9&T>7Fyq9>|Q280m&T|wL-!g|etP3)6*Du8OOgO9?r7j&ThyOHU@ajU1 zvXnHtO`o8v+m{JgtcF;9#f<;2D}Uv2B98$@X(b&Ie~`~r#R-^*56IqE=c~7PAJN{u zX;}8ue=Lp;sw}*5sXn%2;wU+QqHL=fHiO(Z(3tX=HFQ6JU9H?w|3U5U$6fgleF-vx z+`3@U>agp}xRUa6NZkA%iR@YRO4kk@xq2vOWJ$eZjX|p!Y?Y8gzpYV9G&5hN(p0M_Sexz?f7&C%ZCVesm{jaBRDLjnTa8zn zF212lhA-MAi9HtIe6DB^I)=O_HQ!)nSHTs`^H(#W@%&+LmxcFS?lw*tm&=XsR*c6~ zHphueKBL3N8xnwwY-T;j^f0g{$W0Q>`ntiodan1F?gB{cEfg0ZaPLt^2im0!W7C3}2P3Ga++neyPQkEjSpH>%=8$B;? z-l$*ZgUUUINdjig2ej|d%6lFjZqJ^h#WEWr?PFVlH<-D^v#~O8W235DCg!eGNqFGL z-KNgsi5H#(XIre2hS#yFh*2V5=h|L?`sf8_A4ZAt{dGzLZ-|`lw9xfrKRhp%!HE%& zgBp_}Dn zH^m&f|9z+H2@EBzVYck))2AD(<&$aq9yp-}e&U#9Lm-E>8L z{G6a^xWF8O3}d_rRY?&iyYtcKbgsfio{&O|P>49R#*mdf@d61uN#(_4yJ#lAO)*9Y zS*XP(88eZMm9pY#2;Q)DKrMftnPxsOzPll&Q_~ItPfdB^nE009b_>XqZ`*^Q<`51s zfQ$TiKSw`_`PBEK`pRczD{9UxU@wWTD_{GB7BRDq7@aLwrCMb7)kft$I-Y-c`eDuo zdk(jOBqo=FNqm?bv~6!9fqUHujWGN?_)=6qv*G{>>EFl&{1?L(;Z;YeGXt|b5tZ3pP`jJhs^HmkX>qCrbeA2?n;~POk)mJll6-`$@h_(`;@!X-^ zaj~OCL zVplGBv6NI$V5h3WN*N6Z-VleGV#@W8e6#_Vp#8Ty2qiStUXqO^(q_T_wc&$hBYVjg z2D4m!^^%X=idyULMbGLD<>OEvp6GXF!~8p4U`>+bnOk>(6Zf%Q#8BktUEnCD?d*LU zy#3^#q(T;ku|O&;Kr44#ZJy5Z*DASmT+*@^_~P(0)XJSSL8?XGxb_v&6lz<;Zq=}F zOlKtO+{(|AmC9kAq|X!A7{ckSjbdUJ-r98D4lQLX+eaTjqUG86-2ssm3JZ zk@t8*o~*D*nF>(`cpM{7)+U~y*5pYGz<6A)ebl`k4zxiJ#nD2Qnh;IHiVCB16UIQe z09@;K)VoiWjiPXB`BPmE$ruP@J1|d$gz&SFFYb*y^Bwc zogEf>9i}(T%V%O?lyE=D<({t~^{94Zl6on1qvCQoleq>c`cyKJdBqw!bmzr?^P~p))PU{x;{AQ-;)$xMPz`!S1>1 z$D%tksp=MpIS<5oKVsHo4RJLYzY)9X;ul0W_Rm42B; export const SectionCreated: Story = {}; + +export const ChatMoved: Story = { + args: { + type: "chat_moved", + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx index e64d17ffb6..e9f4303e52 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx @@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event"; import * as stories from "./RoomListToast.stories"; -const { SectionCreated } = composeStories(stories); +const { SectionCreated, ChatMoved } = composeStories(stories); describe("", () => { it("renders SectionCreated story", () => { @@ -21,6 +21,11 @@ describe("", () => { expect(container).toMatchSnapshot(); }); + it("renders ChatMoved story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("calls onClose when the close button is clicked", async () => { const user = userEvent.setup(); render(); diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx index 9388cd93f5..e46b000fd4 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx @@ -12,7 +12,7 @@ import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check" import styles from "./RoomListToast.module.css"; import { useI18n } from "../../../core/i18n/i18nContext"; -export type ToastType = "section_created"; +export type ToastType = "section_created" | "chat_moved"; interface RoomListToastProps { /** The type of toast to display */ @@ -37,6 +37,9 @@ export function RoomListToast({ type, onClose }: Readonly): case "section_created": content = { text: _t("room_list|section_created"), icon: CheckIcon }; break; + case "chat_moved": + content = { text: _t("room_list|chat_moved"), icon: CheckIcon }; + break; } return ( diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap index b0e8906e3b..05d35de67a 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap @@ -1,5 +1,61 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[` > renders ChatMoved story 1`] = ` +
+
+
+
+ + Chat moved +
+ +
+
+
+`; + exports[` > renders SectionCreated story 1`] = `
", () => { onLeaveRoom: vi.fn(), onSetRoomNotifState: vi.fn(), onCreateSection: vi.fn(), + onToggleSection: vi.fn(), }; const renderMenu = (overrides: Partial = {}): ReturnType => { @@ -240,4 +241,59 @@ describe("", () => { expect(mockCallbacks.onCreateSection).toHaveBeenCalled(); }); + + it("should render section items in move to section submenu", () => { + const sections = [ + { tag: "m.favourite", name: "Favourites", isSelected: false }, + { tag: "element.io.section.custom1", name: "Work", isSelected: true }, + { tag: "element.io.section.custom2", name: "Personal", isSelected: false }, + ]; + + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel({ ...defaultSnapshot, sections }, mockCallbacks); + return ; + }; + render(); + + const favouriteItem = screen.getByRole("menuitem", { name: "Favourites" }); + expect(favouriteItem).toBeInTheDocument(); + expect(favouriteItem).toHaveAttribute("aria-checked", "false"); + + const workItem = screen.getByRole("menuitem", { name: "Work" }); + expect(workItem).toBeInTheDocument(); + expect(workItem).toHaveAttribute("aria-checked", "true"); + + const personalItem = screen.getByRole("menuitem", { name: "Personal" }); + expect(personalItem).toBeInTheDocument(); + expect(personalItem).toHaveAttribute("aria-checked", "false"); + }); + + it("should call onToggleSection when a section item is clicked", async () => { + const user = userEvent.setup(); + const sections = [ + { tag: "m.favourite", name: "Favourites", isSelected: false }, + { tag: "element.io.section.custom1", name: "Work", isSelected: false }, + ]; + + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel({ ...defaultSnapshot, sections }, mockCallbacks); + return ; + }; + render(); + + const workItem = screen.getByRole("menuitem", { name: "Work" }); + await user.click(workItem); + + expect(mockCallbacks.onToggleSection).toHaveBeenCalledWith("element.io.section.custom1"); + }); + + it("should not render section items when sections array is empty", () => { + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel({ ...defaultSnapshot, sections: [] }, mockCallbacks); + return ; + }; + render(); + + expect(screen.getByRole("menuitem", { name: "New section" })).toBeInTheDocument(); + }); }); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx index e03504ad01..00690a445b 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx @@ -17,6 +17,7 @@ import { LeaveIcon, OverflowHorizontalIcon, ArrowRightIcon, + CheckIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../../core/i18n/i18n"; @@ -136,6 +137,21 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { /> } > + {snapshot.sections.map((section) => ( + vm.onToggleSection(section.tag)} + onClick={(evt) => evt.stopPropagation()} + hideChevron={true} + aria-checked={section.isSelected} + > + {section.isSelected && ( + + )} + + ))} + )} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemNotificationMenu.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemNotificationMenu.test.tsx index 81db73e03f..f7fb857077 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemNotificationMenu.test.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemNotificationMenu.test.tsx @@ -28,6 +28,7 @@ describe("", () => { onLeaveRoom: vi.fn(), onSetRoomNotifState: vi.fn(), onCreateSection: vi.fn(), + onToggleSection: vi.fn(), }; const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType => { diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx index 165d38d267..21bf2806d2 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx @@ -39,6 +39,7 @@ const RoomListItemWrapperImpl = ({ onLeaveRoom, onSetRoomNotifState, onCreateSection, + onToggleSection, isSelected, isFocused, onFocus, @@ -58,6 +59,7 @@ const RoomListItemWrapperImpl = ({ onLeaveRoom, onSetRoomNotifState, onCreateSection, + onToggleSection, }); return ( void; /** Called when creating a new section */ onCreateSection: () => void; + /** Called when toggling a room's membership in a section */ + onToggleSection: (tag: string) => void; } /** diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts index f01243eb09..bf0cb0189e 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts @@ -37,4 +37,21 @@ export const defaultSnapshot: RoomListItemViewSnapshot = { canMarkAsUnread: true, roomNotifState: RoomNotifState.AllMessages, canMoveToSection: true, + sections: [ + { + tag: "m.favourite", + name: "Favourites", + isSelected: false, + }, + { + tag: "element.io.section.work", + name: "Work", + isSelected: true, + }, + { + tag: "m.lowpriority", + name: "Low Priority", + isSelected: false, + }, + ], }; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts index 13db309fd0..72f2f98119 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts @@ -12,6 +12,7 @@ export type { RoomListItemViewModel, RoomListItemViewActions, RoomListItemViewProps, + Section, } from "./RoomListItemView"; export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts index 806e2e76ff..8e79c52cc5 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts @@ -20,4 +20,5 @@ export const mockedActions: RoomListItemViewActions = { onLeaveRoom: fn(), onSetRoomNotifState: fn(), onCreateSection: fn(), + onToggleSection: fn(), }; diff --git a/packages/shared-components/src/room-list/story-mocks.tsx b/packages/shared-components/src/room-list/story-mocks.tsx index 148baec2a1..5d2e615859 100644 --- a/packages/shared-components/src/room-list/story-mocks.tsx +++ b/packages/shared-components/src/room-list/story-mocks.tsx @@ -106,6 +106,7 @@ export const createMockRoomSnapshot = (id: string, name: string, index: number): canMarkAsUnread: true, roomNotifState: RoomNotifState.AllMessages, canMoveToSection: true, + sections: [], }); export function createMockRoomItemViewModel(roomId: string, name: string, index: number): RoomListItemViewModel { @@ -123,6 +124,7 @@ export function createMockRoomItemViewModel(roomId: string, name: string, index: onLeaveRoom: fn(), onSetRoomNotifState: fn(), onCreateSection: fn(), + onToggleSection: fn(), }; }