From f46e432d90f5110f0781cd4d45e8cddeeda7f293 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 25 Apr 2025 15:56:13 +0100 Subject: [PATCH 1/8] Secondary filters --- .../RoomListPanel/RoomListFilterMenu.tsx | 121 ++++++++++++++++++ .../RoomListSecondaryFilters.tsx | 8 +- .../rooms/RoomListPanel/textForFilter.ts | 29 +++++ src/i18n/strings/en_EN.json | 7 + 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/components/views/rooms/RoomListPanel/RoomListFilterMenu.tsx create mode 100644 src/components/views/rooms/RoomListPanel/textForFilter.ts diff --git a/src/components/views/rooms/RoomListPanel/RoomListFilterMenu.tsx b/src/components/views/rooms/RoomListPanel/RoomListFilterMenu.tsx new file mode 100644 index 00000000000..2d57e397e33 --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/RoomListFilterMenu.tsx @@ -0,0 +1,121 @@ +/* + * Copyright 2025 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 { IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web"; +import React, { type Ref, type JSX, useState } from "react"; + +import { _t } from "../../../../languageHandler"; +import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel"; +import { + ArrowDownIcon, + ChatIcon, + ChatNewIcon, + CheckIcon, + FilterIcon, + MentionIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { SecondaryFilters } from "../../../viewmodels/roomlist/useFilteredRooms"; +import { textForSecondaryFilter } from "./textForFilter"; + +interface MenuTriggerProps extends React.ComponentProps { + ref?: Ref; +} + +const MenuTrigger = ({ ref, ...props }: MenuTriggerProps): JSX.Element => ( + + + + + +); + +interface FilterOptionProps { + /** + * The filter to display + */ + filter: SecondaryFilters; + + /** + * True if the filter is selected + */ + selected: boolean; + + /** + * The function to call when the filter is selected + */ + onSelect: (filter: SecondaryFilters) => void; +} + +function iconForFilter(filter: SecondaryFilters, size: string): JSX.Element { + switch (filter) { + case SecondaryFilters.AllActivity: + return ; + case SecondaryFilters.MentionsOnly: + return ; + case SecondaryFilters.InvitesOnly: + return ; + case SecondaryFilters.LowPriority: + return ; + } +} + +function FilterOption({ filter, selected, onSelect }: FilterOptionProps): JSX.Element { + const checkComponent = ; + + return ( + { + onSelect(filter); + }} + > + {selected && checkComponent} + + ); +} + +interface Props { + /** + * The view model for the room list view + */ + vm: RoomListViewState; +} + +export function RoomListFilterMenu({ vm }: Props): JSX.Element { + const [open, setOpen] = useState(false); + + return ( + } + > + {[ + SecondaryFilters.AllActivity, + SecondaryFilters.MentionsOnly, + SecondaryFilters.InvitesOnly, + SecondaryFilters.LowPriority, + ].map((filter) => ( + { + vm.activateSecondaryFilter(selectedFilter); + setOpen(false); + }} + /> + ))} + + ); +} diff --git a/src/components/views/rooms/RoomListPanel/RoomListSecondaryFilters.tsx b/src/components/views/rooms/RoomListPanel/RoomListSecondaryFilters.tsx index f162c38f776..6326fe6d7c4 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListSecondaryFilters.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListSecondaryFilters.tsx @@ -11,6 +11,8 @@ import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListVie import { Flex } from "../../../utils/Flex"; import { _t } from "../../../../languageHandler"; import { RoomListOptionsMenu } from "./RoomListOptionsMenu"; +import { RoomListFilterMenu } from "./RoomListFilterMenu"; +import { textForSecondaryFilter } from "./textForFilter"; interface Props { /** @@ -23,13 +25,17 @@ interface Props { * The secondary filters for the room list (eg. mentions only / invites only). */ export function RoomListSecondaryFilters({ vm }: Props): JSX.Element { + const activeFilterText = textForSecondaryFilter(vm.activeSecondaryFilter); + return ( + + {activeFilterText} ); diff --git a/src/components/views/rooms/RoomListPanel/textForFilter.ts b/src/components/views/rooms/RoomListPanel/textForFilter.ts new file mode 100644 index 00000000000..efb748937ad --- /dev/null +++ b/src/components/views/rooms/RoomListPanel/textForFilter.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2025 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 { _t } from "../../../../languageHandler"; +import { SecondaryFilters } from "../../../viewmodels/roomlist/useFilteredRooms"; + +/** + * Gives the human readable text name for a secondary filter. + * @param filter The filter in question + * @returns The translated, human readable name for the filter + */ +export function textForSecondaryFilter(filter: SecondaryFilters): string { + switch (filter) { + case SecondaryFilters.AllActivity: + return _t("room_list|secondary_filter|all_activity"); + case SecondaryFilters.MentionsOnly: + return _t("room_list|secondary_filter|mentions_only"); + case SecondaryFilters.InvitesOnly: + return _t("room_list|secondary_filter|invites_only"); + case SecondaryFilters.LowPriority: + return _t("room_list|secondary_filter|low_priority"); + default: + throw new Error("Unknown filter"); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 234e6d7eb35..895aa373e26 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2121,6 +2121,7 @@ "failed_add_tag": "Failed to add tag %(tagName)s to room", "failed_remove_tag": "Failed to remove tag %(tagName)s from room", "failed_set_dm_tag": "Failed to set direct message tag", + "filter": "Filter", "filters": { "favourite": "Favourites", "people": "People", @@ -2154,6 +2155,12 @@ "open_room": "Open room %(roomName)s" }, "room_options": "Room Options", + "secondary_filter": { + "all_activity": "All activity", + "invites_only": "Invites only", + "low_priority": "Low priority", + "mentions_only": "Mentions only" + }, "secondary_filters": "Secondary filters", "show_less": "Show less", "show_message_previews": "Show message previews", From 9671f17849f7b4f676cb9acb823211bb9f789afb Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 28 Apr 2025 17:32:40 +0100 Subject: [PATCH 2/8] Update snapshots --- .../__snapshots__/RoomListPanel-test.tsx.snap | 76 +++++++++++++++++-- .../RoomListSecondaryFilters-test.tsx.snap | 38 +++++++++- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap index 9bb7061ba6d..e51c4b8ca0f 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap @@ -116,17 +116,49 @@ exports[` should not render the RoomListSearch component when U
+ + All activity + All activity + + All activity + + +`; + +exports[` shows 'All activity' checked if selected 1`] = ` + +`; + +exports[` shows 'Invites only' checked if selected 1`] = ` + +`; + +exports[` shows 'Low priority' checked if selected 1`] = ` + +`; + +exports[` shows 'Mentions only' checked if selected 1`] = ` + +`; From 59e24c3da821c494c1a60028b39a24d1ca6e546b Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 29 Apr 2025 13:35:13 +0100 Subject: [PATCH 6/8] Imports --- .../rooms/RoomListPanel/RoomListFilterMenu-test.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx index d65948caf31..9eb4435f07f 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx @@ -5,14 +5,16 @@ * Please see LICENSE files in the repository root for full details. */ -import { render, RenderOptions, screen } from "@testing-library/react"; -import { RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; +import React from "react"; +import { render, type RenderOptions, screen } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; +import { TooltipProvider } from "@vector-im/compound-web"; + +import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; import { SecondaryFilters } from "../../../../../../src/components/viewmodels/roomlist/useFilteredRooms"; import { SortOption } from "../../../../../../src/components/viewmodels/roomlist/useSorter"; import { RoomListFilterMenu } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListFilterMenu"; -import React from "react"; -import { TooltipProvider } from "@vector-im/compound-web"; -import userEvent from "@testing-library/user-event"; + function getRenderOptions(): RenderOptions { return { From 220c716875b3aa5c9e30abc60271f48a3b314ae6 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 29 Apr 2025 13:49:52 +0100 Subject: [PATCH 7/8] Prettier --- .../RoomListPanel/RoomListFilterMenu-test.tsx | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx index 9eb4435f07f..3e14a64f8ef 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListFilterMenu-test.tsx @@ -15,12 +15,9 @@ import { SecondaryFilters } from "../../../../../../src/components/viewmodels/ro import { SortOption } from "../../../../../../src/components/viewmodels/roomlist/useSorter"; import { RoomListFilterMenu } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListFilterMenu"; - function getRenderOptions(): RenderOptions { return { - wrapper: ({ children }) => ( - {children} - ), + wrapper: ({ children }) => {children}, }; } @@ -54,17 +51,17 @@ describe("", () => { const userevent = userEvent.setup(); render(, getRenderOptions()); - await userevent.click(screen.getByRole("button", {name: "Filter"})); - expect(screen.getByRole("menu", {name: "Filter"})).toBeInTheDocument(); + await userevent.click(screen.getByRole("button", { name: "Filter" })); + expect(screen.getByRole("menu", { name: "Filter" })).toBeInTheDocument(); }); it("shows 'All activity' checked if selected", async () => { const userevent = userEvent.setup(); render(, getRenderOptions()); - await userevent.click(screen.getByRole("button", {name: "Filter"})); + await userevent.click(screen.getByRole("button", { name: "Filter" })); - const shouldBeSelected = screen.getByRole("menuitem", {name: "All activity"}); + const shouldBeSelected = screen.getByRole("menuitem", { name: "All activity" }); expect(shouldBeSelected).toHaveAttribute("aria-selected", "true"); expect(shouldBeSelected).toMatchSnapshot(); }); @@ -74,9 +71,9 @@ describe("", () => { vm.activeSecondaryFilter = SecondaryFilters.InvitesOnly; render(, getRenderOptions()); - await userevent.click(screen.getByRole("button", {name: "Filter"})); + await userevent.click(screen.getByRole("button", { name: "Filter" })); - const shouldBeSelected = screen.getByRole("menuitem", {name: "Invites only"}); + const shouldBeSelected = screen.getByRole("menuitem", { name: "Invites only" }); expect(shouldBeSelected).toHaveAttribute("aria-selected", "true"); expect(shouldBeSelected).toMatchSnapshot(); }); @@ -86,9 +83,9 @@ describe("", () => { vm.activeSecondaryFilter = SecondaryFilters.LowPriority; render(, getRenderOptions()); - await userevent.click(screen.getByRole("button", {name: "Filter"})); + await userevent.click(screen.getByRole("button", { name: "Filter" })); - const shouldBeSelected = screen.getByRole("menuitem", {name: "Low priority"}); + const shouldBeSelected = screen.getByRole("menuitem", { name: "Low priority" }); expect(shouldBeSelected).toHaveAttribute("aria-selected", "true"); expect(shouldBeSelected).toMatchSnapshot(); }); @@ -98,9 +95,9 @@ describe("", () => { vm.activeSecondaryFilter = SecondaryFilters.MentionsOnly; render(, getRenderOptions()); - await userevent.click(screen.getByRole("button", {name: "Filter"})); + await userevent.click(screen.getByRole("button", { name: "Filter" })); - const shouldBeSelected = screen.getByRole("menuitem", {name: "Mentions only"}); + const shouldBeSelected = screen.getByRole("menuitem", { name: "Mentions only" }); expect(shouldBeSelected).toHaveAttribute("aria-selected", "true"); expect(shouldBeSelected).toMatchSnapshot(); }); @@ -110,7 +107,7 @@ describe("", () => { vm.activateSecondaryFilter = jest.fn(); render(, getRenderOptions()); - await userevent.click(screen.getByRole("button", {name: "Filter"})); + await userevent.click(screen.getByRole("button", { name: "Filter" })); await userevent.click(screen.getByRole("menuitem", { name: "Invites only" })); expect(vm.activateSecondaryFilter).toHaveBeenCalledWith(SecondaryFilters.InvitesOnly); From f57cfa5c85f9a58f49279c6e86911c9fadb9aadf Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 29 Apr 2025 15:29:19 +0100 Subject: [PATCH 8/8] Add playwright test --- .../room-list-filter-sort.spec.ts | 27 ++++++++++++++++-- .../filter-menu-linux.png | Bin 0 -> 10396 bytes 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/filter-menu-linux.png diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts index 0ce13173b87..1d7ce54e8a7 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts @@ -6,7 +6,7 @@ */ import { expect, test } from "../../../element-web-test"; -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; test.describe("Room list filters and sort", () => { test.use({ @@ -18,10 +18,14 @@ test.describe("Room list filters and sort", () => { labsFlags: ["feature_new_room_list"], }); - function getPrimaryFilters(page: Page) { + function getPrimaryFilters(page: Page): Locator { return page.getByRole("listbox", { name: "Room list filters" }); } + function getSecondaryFilters(page: Page): Locator { + return page.getByRole("button", { name: "Filter" }); + } + /** * Get the room list * @param page @@ -106,6 +110,11 @@ test.describe("Room list filters and sort", () => { await app.client.evaluate(async (client, favouriteId) => { await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 }); }, favouriteId); + + const lowPrioId = await app.client.createRoom({ name: "Low prio room" }); + await app.client.evaluate(async (client, id) => { + await client.setRoomTag(id, "m.lowpriority", { order: 0.5 }); + }, lowPrioId); }); test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => { @@ -137,7 +146,19 @@ test.describe("Room list filters and sort", () => { await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible(); await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible(); - expect(await roomList.locator("role=gridcell").count()).toBe(3); + expect(await roomList.locator("role=gridcell").count()).toBe(4); + }); + + test("should filter the list (with secondary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => { + const roomList = getRoomList(page); + const secondaryFilters = getSecondaryFilters(page); + await secondaryFilters.click(); + + await expect(page.getByRole("menu", { name: "Filter" })).toMatchScreenshot("filter-menu.png"); + + await page.getByRole("menuitem", { name: "Low priority" }).click(); + await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible(); + expect(await roomList.locator("role=gridcell").count()).toBe(1); }); test( diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/filter-menu-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/filter-menu-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..babee9bb0b0e0cb7444c2573b51812ef470aad81 GIT binary patch literal 10396 zcmcI~1yCIA*5-uZ9tiFPNYF{};O-LKEikyd6M_Z*@SuYeJh;0I?t=#n?k+*LQ-5vk zUAeWlYHO*1rkEb8`|anPN6s7mNl6+DofI7e0%6I@NT>qG0pR-r6#@9E5H?l>9A3Dn zN{fLi#>n?TAZn1Tgs8e_#?dbeef$Mdlr;`Cv_02+IdD$OgpJ56m8zTiiZ+bvV_lD5 z@Rt3>h8Fij$Sb6Lk9@f}LsnNY>PLTKa7rCDveZ!4PEZGLYHCJsz?cHVY5O-3Y7j_< z4i6-`AOrQU7Z#gd@L zbyXzzN?liZkgmsziY;BFYN^hiDMy2c_INLm9T);5a$Ic8sK1`&uFz&t`6sWq_+9S0 zsFnyW;`+s1CVr9(-54`dkqTq~{oI}UT=_soRi(fqis} zC@--nTuBR}mjZ3J9M_|eu4qV;riRuxZV_l6`jPj=t0fSOs5`H2-e>2aA!BqSViE?U z5woD;zt_fPF#buc&}l6C{Y%%^_b-BwOc4I{1a@ePsE!wW@FIY-q1K%(F2#by;649G z*1X1i-={Lh4DREmRkn}SwuMnRKT0Y_N5QrA#@04gmsbNhFOF)0LR3{WtKW0Pb#1-? zx$-2dchGa{e z8jnrCJylCPpGUv6v9^1B&Ly$(Ej9|yL{L^@*F7C#SWOjsrGsUZtjyV&vzuG3!TyUVzHW$?bh$jgxOKUF+6+xjAJ>$`UU}5-$}l>(e=G zrfw%sD)W9&3oB?yBFD~6ebUlWQ8J95#Vwue!e$r*U@$e6*i8_&@9&2wN{rbYg}E}h(m%F{&;?+QHZVK4EHOTr z@fUG+Am-Av8EqUjUgSBjgu~2OSgu@f_&IvS{yrH$pC^vk8!@QF8Pd=5*@&3+UgaKqKv0)No=}b0M1KN zRoacIHG(()0h0a|YX7_dsn5*b{Dq?wGU&a&L2*ae3oJ%SddTS~)c+ZP|N622BSQb< z6E1b&W|Xl;+S=cG)}x{@!zDf&9TAe|dy|#z@25RKcnUCZS2Q;ly5uA$7Z!#2IG*a( zne=!AMg3_Xo^$#X)X_Horc%F6$ael`fjnX6!yYU___5J-kQPE;AfHm{?H!GZ;X`{h zCOyxb?i-e1^Khm6bkdD%6#=!DK6sO`X7gr73_s3 z(}UYir_GnJqvfirr#8JX>|^{$2_vxX{cqXe*Ze6mbR;A?Nl6;j)zwaCXjfPBgfG5D zM>m*$q!M(V@M>7aAu0nQs6b zI==JlcIGT9YU1EdyOnb~n%>N1Glw7E+d;Mbfs@lSAf(B~{NqQyTNK0!Lo#wIvf}LQ zQ=LB!4i2%=(PZ2T@X5fdcZae{V=i1=b>L|y>{wVV+9?(O+UldVre?Bm<}D6ixPPFE z-v?izfU|gl^;dCEc@O?V0lNE-G9OJdgJ7MsfJ>aH$RFQ6^Y zLqjE(79@oX{kHW)U%z~xZ&bbx+^2hu-qZ7DC!lU%q`SXs(C;Go2rXQ8H@2g=>F(jq z=;d3558QR+{Pr7g8yY)1J0dpyj1J@VgE~Cmj20ec7-Wjo4 zTUv_1622$eo~%{7Esb4YUyu7yw7(aEK3{bg0Jqr8$A^M*cr;p(kCrS$*X(`77b2F* zZ=rc(GnUSuE>~dK?0aSUmYIrT+hI|p zXu3!-{d_w7nV5_u_m3!U@8V*Fl{Fp_R9j061!QSu6&@1~pPF%Wb`@Fb+&H_fBpQ*1 zZWl%$+#If$*pIooxD5`DvcB^T%&O|^qen#)Gos=Sm)O6HJl)DE_9i7LFU@ng=-1HD zNLo2mOcOWp_ZM|>iLAu=lOX8s#G8@#lKI?9UDPC}I9wLzW_-Q2ueX12cz9uHsko%1 zv6=Vzeu)aeF0e#`3(N4nyW=CW^Ye2|WP^9F(GeenpB@h2rE|1Zx3ZqS-(Cp`lMChN z=W}{Z^+u+!F*7q_scCSTvJaZYSH#4$W!lZXtccIeEh>Tmpf)pyl$Mruc&cQiUkFfl z8mYJ6>s_qJj=oHvk`qjEv>s;HayvCLb6z z4G}Y;DwG={IoK-I0)dE#in7WMcXvUcALV`v7mq0^DHafj-uLu_y*<2e2_7vAJXvlg zzR=;VYhxP`YTRJ4_0*aji?J*oKI+r?vjl`dk#~5>DJeXhoO~bo28M@Uf`~Ei&mr=K zN|_2dpPCnvT3VPhl_JB#ZS1ys-_oH=gIQx&FfZ-bkgn-33{3N-%?WG=R@vHGSIOSq*EhGgIJ_xU=O+Cf6VuPOHlXXJBxl9_^Ur|s z`{iHy5FvQ*HAbVybJyb^oEjHr`_+bCuJxQ(`se?+(uZL?k4rVPajj ztY*MA>FMd2nVB;M9}~jEKS0O9Y>WhveW+h|cX!{t8-Vq_Rmi~o@#Dw*bc6R6B zq^OZmWtcLR+Mo7?%%mg(RaHK}{tPl2tx2_zK6xzxfAWfg0xyo=5zWosMuN$lVPa}( z_@vQk?tyIvVY`bVtD5{4JBebo^RR#${a5~ zzVGvg5V^QG8lW^JCHq=kMlnTkliM^3?(fIcRcn@)ZFEKJ=e& z{+H9!({9EKt4%(Y*IUd+tAWmo6}h>-JDT6x+YJrYiCx{B zXL%1&W)?d$?GL@FYM!2E_Qw~+Y7950pMYA$Pf#UIPg~YE*mc$2arS%Zhv9NDQg*nJ zyK#IYLWj3AlJyFA?fC-S;os(HrD%6F!Z-e?Jqmb@VG86EZpASX2L}!*S_}lZa!I#>{)MZ6_~fJT=jo; z8PD{;=`n{|o+oCI4z%s#6a8G-x6F|9vW@m{KYFW@@(UCebZ&MracTg`vWN&iP>E9?C`uT9x_DwSy@4(oo)~eCfEr{{JQD{+gEE;j!0}l9C|Md@90zka2k5#sLkFxR^Zy z1ASfqT6E94enw`YGL+9rY^{@V^`)Ol8@i^I&cYl#Axv$23H2Zq78k$s>qcNg0NzK-d`c6yDL zBptB2=NBM1x2tK3F*vFHd;g6Y067&Nl9Dj(1zt-m9$x25VqN2g6gfG$BBk^?Z^cg* zRnIRF^0-Bf;uE|09v&x4O7q*JTUVzTeG5VD=_P4tz_lVYx_Y&gc(LwLIJ}fjt z)ci%e7F;@H&TvCQz*!J8H_fP{wKG6p-mt%*jr(D<-Pr~Fp~;N>rP9GcMUYT(6*=-hYhPCMF7{dELV~5pvD^Bcw z*bKP4y4mB{Ax+nf!N_=xj}O~&Zm?U0*PSR@>)!vE+)QTETL3f=<4()|&U}#Ja=vv4^8`L$%Yb`D$QcAp=N6O>O!{_yN{4tR+1@KiIbk zv|iwGta`BlJ%WqN?x^{S1w~8GrwHKUzPEJl9AgznCWr`Ku`s3YI`hjdHz0$tEoUD4 z#r?J2P)b5%7OSsU`&-PlHK>bU==u3A6gn*l2q2e+1O!_jUgSX`X3o6ipf5Ew_#jYQ zFWiNb?^htiNX;N2F_8r?oR?flU7a&kp}MB7Z*z4_J5^_ajN8`h@qRS)N;I7t5#H`w z=ijp(HNpb=B7tyzfk7#ZMn$$J9I%p6eI6mc>i4`4Kaj*Aay|0tF`nS3I=edHS*ky| ze~iUo@oC9+E7eQWHb!uyJ{GC5hrnuZ3hW2cY{f0|Jf$x!Mb%?4ypw%R2B9AJe2 zyIfZXj*h~-a&-fNq7_G>SGb)8(Y8p~1-b*g^g?Cnu*Z)4*ES8v;p+i!+h4)-^C_J8^cObKZLS4=2a$dkqbc zh`T#BNJLLh-pJ_WEa7x4)=Dhr^en*<)S2GgJQmQ>t}19|YxaBNFfl1JGd-P%3OX@A z&*_|BnUO*64Q^-?1g^BY+PXX6O1B5AHk3>ui#-qU!S&ble0W}7UK#`hwXOwUzqU%y zu0Olb?g8d;o9Hzjs)WT`>DqyU(9q`0az!sMq(wK(SQwz6b5H2elf-gV`S|!wJzLY$ zF_Pr;YTO_589CY?kHgN-o$>al?A#lVj>a#}>uPIRSXprq0S^*oRZ>)xo?4=^&53aSq$PIQcqtSEM6EU;By{)_wk|u!=yi9;$Jawaq4O-P zAVc>#Zhm0E@zfO}D#e0%U=YV8kWoE|p5cUS({?~-rHln0_qS`$ii#2?S;gr|k`+Y~*Ki3{9UTn=H4cxlG8YSFL2RGS z>IQjPMH^e|ef=;ohV&1L!^}(^>@^dSVIP~Cz!4Ftg$(MXbz$HTfTUW|ZS2P`T5c$T zxR!g#W^E2T`8@B>zQ!=vu({g#su|_Ro`iU}QoG%Ca3e%VX94h4ot+@RMVFlsCT8Y_ zhK9a?F|t}_2n15!umhOr*ROy3%^Ox5RB#Ut4IxKS5)fXSovgg9F&_o=dZWvZp}*+* zO&Crtlu76>U!f;WE!d-p;_~ni)i>M#&1Mb`2kv#H&8MTe8PGXfzsP%+mX?$=g~;%n zT~=B@Qc~JCHF@>X)8|S58U8?=d4L<TN|5$J9(3S z&bs>gh?tnGD+ot3qDNH39DdUD6`o4+lhbG+BeD zx@Oi#x6hsKExUA(mw`%wfu?|>jUj(LJ8nbS8)pZBRt&QO3~tL$q)=Z_#zMa*zNVlc z!zXL7-OKGdU!rYGtKzF`Xs|GcMD~4l;o$h#B>G+jhgL%aE}v5Fo0TFANM6`AD^QAm zd?cW=OC+WEsS57y&W8yGq9+qmQ@)PR?g6nIHCVfmwi9be|7$Y+ALoAmHrxLDAKv3= zT3M0kwqV|Cb#ZIM;arr4?Hy0|D21^ISR$%VUDkjAc|^ep>Bdoz#6&hd%SGC?tJO7= zx3M`vM#B=CLi`&>7#_w^wahIIy8v(FJo{fBRCeS+I4TWrWMyP(f4*O>@sU1}!oVV( zLS*J*r2yfQ*Um27y$sN-EXzj(4UCM)Dac8ssB6suuU79XQse_8iHPL49w#evGqchd z5*}dT4iPoI=dd@z*SI5fny9~)4=BKKitiG%jBISF>~7m7@Pqw>gM#^1hot1B zySrF}YF9~ldHI0Yt;qQHEm@qLx(CNb@Z8HXo0`AYW74$WBiSjeJzwbH{8SVf`JTVt z0gapod0Ki8-HtnO#op4hAzApc&zRqo2DRFD?=uz_)*d&Q`7ikO>px$GLzVtSlbUwd z>ehOC0hJL+B-#E(di>d1#lj}D@fMLk`Qnc9H`Sucvc;!!FL^)PBJ6xlZM@m|7|{cmsHFjAtH zE07ZqTx|43B4KRDo|C0WpHn5&OD2O+e=6 zv@ru@ZX~Yvh0$F-GochuPrEqM{=c1@sL1HNi;?=Wr~Q2A-Q3(58H;J-WN71nI4UJE zvAV4-F)69Q2ZGFNg&pExbQYjl`}F<3`5s6%Hw^WCdDz*PtG$9kL;K%#yu!kY2JHON z5d~0r9>*)OvC3u(OYpfl*jqYwMa8l*mZl?pS7ziOdx({2-n4pR5(R}e5CC^PKEY7< zbu4XcxHvg~l$6Afkr_iE9~nBI0hL$Mquw$*#mdU+^FZg@iwdQ}rczZ^O=I_(sy}L> zg-R)=b4wZ;9tQ>4?oUO%1nlcaJ|H5Ll!nFNkOL_T83o0#@r0n@ua^2MVmah4kC1)q z9!pL>-9=qPLqlp@AhME|$6%P|6cWk-h>D-Tp|*BpX{n~B_4-fqo|z~^obUV!&=b^( z#dk2I*=F159>fk0iNHWm8Kjz>e1Oi9V{@rw(@grc|x1_nq-ca68f+uPW z3=AfR5(m(FOR0Wll77h+Rf5rsSLt35trfyar0TigC7i6FIZN6Q1cjwM~d=#1=?CxH+ z-8pXC+S+2)Vwr}+!@qeBZPllyerOp}M!xq}D^d`0Ka}`%%0NYRGCzO&ZD0T*pYlB| zt=0FgI_xEoTi%?Vtpu$7qs^kzW*rhCSJ}*r={QA&?h#N!1v0j7o&!BPs;j3mK62a~^EU$41nVFundRv|D3b*hv3uPC2jH38 zF7eefPhTKNRwSEK&g5&dDI%neYxJB%KoH$7O0ejDLuVU5p_mPaz8h@$k$=UH*Qe>`UR$2J?u#B>#`6tp8737T%7jDK0K<`ljp) zR-jPDulF7+bDkurEe+!Lj?f8ecnXImS<2EMN?agr;4mM>A{TA~)4PJJ=wUrpS%skg7^!$wOT zH@DFexG2C)c?JqY!_3$JyT=E8`LRpvgOW0;SFa5HI5*S}B-U*FsO zf`S`+&*!&iQe4I?ELQQVoF^I*s!*? z)(ogoZ%@mXb@uJlLE+uj=CfJ zJKk;^ZCjdlH zGc&apJ4$!4=RP|Xz~lf(6dm2%n!NACPNZ%%U0g1HwcNnr@R&!>-jOIqTvSd@PJqbq z$;eLD*X=DWzh@>)&&&jC+BQ^Gjg5{1+H!PobPEHKg;4%FK+v5JmunwkOX2oT zOc-ws)tQ(mv#^f50F{(5k?|n&^M7=Ar;_@kM)$TzV0b{g+H)NUnt%@7E_ArXJh$BP z-0AQe)irbS$)9WvMde-MSPvC;*!0{S(CKL6WVX!}0Ad1x(vRG3R+ju(0cLhOJ2N*g z1g4VW#r|fM=4B_?M=1#jdiB0n(?51Ah5?QM>olt>DcP^`w0H}4H>|W4Gc$u5YTr`{ z>62xmkyB`^qi=Z`QYOjO)zq=EvN8iX^T^25%*^SfBbb#L%*r}EFrcnRX5P=~_iRyE zQj%L*8pJj?J2f-$sj{Txcz0KXqv+o7m3z*o??#_wsBtqh$sv$%U~>+Z8^2!q`8oO} zZzOE9ww5#eg#Ol#aC_U}PhIw}wC24zcH%20?f!l#4|{{6={*c0Rz>B2P&NexIiLcH zGcuMl_2#0Zqwg&<90qYwGwo!|P}x061ayNlAr4@?h$$#!dy7b0SG%#1H(WAzruLFZ zvM-r%8)|Dbae;y0DS++wsWz_LM^V$8bL+omJ92XJ(^DL|xu(RJe<|4GQ5QL9C99Q; zcz3nYKUJY{lSd zzbhb!_V@N`i>tliW@|rIY%4FXi_6IgEtGO~wFTTwLS7HbxIsxtiQ4qkB!h#_!UACE zNJ;m9Wo!)$417-`zVK|ly_KjIrm(0i4xjBp z)}>h_yYGBns*;ectAEmu?JI2Sd9cIOG=a@6Jbx~_B{>Rg(?$a|Kfyf%uafpH=s z;kJPfs~gZ$-zsM^va%*5CAGQkp;Ll4d7LG9y2NmMM@K`Bj{L5d#{uL?NreJ(zp|o2 zr_H%3K7IpMFUDi`o|uAyLS^#9Yo@;0{xk&!V5rFV^RUtoBMt!N34gxQl8PV4BPp=r z?Ot|3d;|KjQY1(F0x$@V*MFdy@)_h5^DBlzLMyA3iyhCM4XcOzUtpUsl$c+DpscRO zTjYjK#<^{7+c8Y5YiNXsP|q*UPwa8Ja1OKECj@Vf4?fPLo;V*X8~rui#nBV!Met%(k8!uH!_vxc0NM_#D_*bo!=7wo*!03 z`bkm};w!dc6kwpLp+d5Lz|jk=M9|*Qp9yNb@NP=sg=V`QAo>CX|K8r-_06~Ufx)oC z`^GE3ah}~(7Fsr}vH#nyr+>@GF*4Mrw_l$qkvUyYZxLwYzT~M4W^mgF;2@Lo@!IK= zhD7D5%4F2%sIi0gJ<7@1qUHK0-~HC)H5K(Ms*9E{_{UM5U6u~-=EgWXN1P@3lPq0E z6sD3&mZ7%lTlHuK|M#7w#Rl!(z`|#ImIDMV``el)Tm#@ zZP34yT3T!oT-|hLcd;IL#T9os)=D6CZ#hre%7SDDM;bnpG7WLu`vgx=PfyImCu!M2 ztOtlrale2-|6E_YbAAS<)3h%_3N&7`5B3^+2H+5oa-v5JHysI>3bG=;uzZCMWOB8~ z5Q};c=t{9XTCp4zJ4cPtl{7@EU;x-#2W*uCt)C$Qx6T~4wAfD&NiTPfDd|O;R`mS5 z1dJ3R{oX#}ba*a}6wBC@MdfYLOV}M3fz9#sct2jVN6Qt)LyBLsQ-(bR