Update right panel base card styling to match Compound (#12768)

* Update base card styling to match Compound

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update screenshot

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-07-16 10:03:35 +01:00 committed by GitHub
parent 5f10ccb5e4
commit f7a078d250
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 118 additions and 130 deletions

View file

@ -81,7 +81,7 @@
"@sentry/browser": "^8.0.0", "@sentry/browser": "^8.0.0",
"@testing-library/react-hooks": "^8.0.1", "@testing-library/react-hooks": "^8.0.1",
"@vector-im/compound-design-tokens": "^1.2.0", "@vector-im/compound-design-tokens": "^1.2.0",
"@vector-im/compound-web": "^5.2.3", "@vector-im/compound-web": "^5.4.0",
"@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2", "@zxcvbn-ts/language-en": "^3.0.2",

View file

@ -227,7 +227,7 @@ test.describe("Cryptography", function () {
await verify(page, bob); await verify(page, bob);
// Assert that verified icon is rendered // Assert that verified icon is rendered
await page.getByRole("button", { name: "Room members" }).click(); await page.getByTestId("base-card-back-button").click();
await page.locator(".mx_RightPanelTabs").getByText("Info").click(); await page.locator(".mx_RightPanelTabs").getByText("Info").click();
await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="success"]')).toContainText("Encrypted"); await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="success"]')).toContainText("Encrypted");

View file

@ -132,7 +132,7 @@ test.describe("Cryptography", function () {
// wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info.
async function awaitOneDevice(iterations = 1) { async function awaitOneDevice(iterations = 1) {
const rightPanel = page.locator(".mx_RightPanel"); const rightPanel = page.locator(".mx_RightPanel");
await rightPanel.getByRole("button", { name: "Room members" }).click(); await rightPanel.getByTestId("base-card-back-button").click();
await rightPanel.getByText("Bob").click(); await rightPanel.getByText("Bob").click();
const sessionCountText = await rightPanel const sessionCountText = await rightPanel
.locator(".mx_UserInfo_devices") .locator(".mx_UserInfo_devices")

View file

@ -538,7 +538,7 @@ class Helpers {
const threadPanel = this.page.locator(".mx_ThreadPanel"); const threadPanel = this.page.locator(".mx_ThreadPanel");
await expect(threadPanel).toBeVisible(); await expect(threadPanel).toBeVisible();
await threadPanel.evaluate(($panel) => { await threadPanel.evaluate(($panel) => {
const $button = $panel.querySelector<HTMLElement>('.mx_BaseCard_back[aria-label="Threads"]'); const $button = $panel.querySelector<HTMLElement>('[data-testid="base-card-back-button"]');
// If the Threads back button is present then click it - the // If the Threads back button is present then click it - the
// threads button can open either threads list or thread panel // threads button can open either threads list or thread panel
if ($button) { if ($button) {

View file

@ -106,7 +106,7 @@ test.describe("RightPanel", () => {
await expect(page.locator(".mx_FilePanel")).toBeVisible(); await expect(page.locator(".mx_FilePanel")).toBeVisible();
await expect(page.locator(".mx_FilePanel_empty")).toBeVisible(); await expect(page.locator(".mx_FilePanel_empty")).toBeVisible();
await page.getByRole("button", { name: "Room information" }).click(); await page.getByTestId("base-card-back-button").click();
await checkRoomSummaryCard(page, ROOM_NAME); await checkRoomSummaryCard(page, ROOM_NAME);
}); });
@ -120,7 +120,7 @@ test.describe("RightPanel", () => {
await expect(page.locator(".mx_UserInfo")).toBeVisible(); await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible(); await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible();
await page.getByRole("button", { name: "Room members" }).click(); await page.getByTestId("base-card-back-button").click();
await expect(page.locator(".mx_MemberList")).toBeVisible(); await expect(page.locator(".mx_MemberList")).toBeVisible();
await page.locator(".mx_RightPanelTabs").getByText("Info").click(); await page.locator(".mx_RightPanelTabs").getByText("Info").click();
@ -145,7 +145,7 @@ test.describe("RightPanel", () => {
await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible(); await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible();
await expect(page.locator(".mx_SpaceScopeHeader").getByText(SPACE_NAME)).toBeVisible(); await expect(page.locator(".mx_SpaceScopeHeader").getByText(SPACE_NAME)).toBeVisible();
await page.getByRole("button", { name: "Back" }).click(); await page.getByTestId("base-card-back-button").click();
await expect(page.locator(".mx_MemberList")).toBeVisible(); await expect(page.locator(".mx_MemberList")).toBeVisible();
}); });
}); });

View file

@ -433,7 +433,7 @@ test.describe("Threads", () => {
await textbox.press("Enter"); await textbox.press("Enter");
await expect(locator.locator(".mx_EventTile_last").getByText("Hello Mr. User")).toBeAttached(); await expect(locator.locator(".mx_EventTile_last").getByText("Hello Mr. User")).toBeAttached();
// Close thread // Close thread
await locator.getByRole("button", { name: "Close" }).click(); await locator.getByTestId("base-card-close-button").click();
// Open existing thread // Open existing thread
locator = page locator = page
@ -486,7 +486,7 @@ test.describe("Threads", () => {
await textbox.press("Enter"); await textbox.press("Enter");
await expect(threadPanel.locator(".mx_EventTile_last").getByText(threadMessage)).toBeVisible(); await expect(threadPanel.locator(".mx_EventTile_last").getByText(threadMessage)).toBeVisible();
// Close thread // Close thread
await threadPanel.getByRole("button", { name: "Close" }).click(); await threadPanel.getByTestId("base-card-close-button").click();
}; };
await sendMessage("Hello Mr. Bot"); await sendMessage("Hello Mr. Bot");
@ -502,7 +502,7 @@ test.describe("Threads", () => {
).toBeVisible(); ).toBeVisible();
// Open threads list // Open threads list
await page.locator(".mx_BaseCard_back").click(); await page.getByTestId("base-card-back-button").click();
const rightPanel = page.locator(".mx_RightPanel"); const rightPanel = page.locator(".mx_RightPanel");
// Check that the threads are listed // Check that the threads are listed
await expect(rightPanel.locator(".mx_EventTile").getByText("Hello Mr. User in a thread")).toBeVisible(); await expect(rightPanel.locator(".mx_EventTile").getByText("Hello Mr. User in a thread")).toBeVisible();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -27,7 +27,7 @@ limitations under the License.
.mx_BaseCard_header { .mx_BaseCard_header {
height: 64px; height: 64px;
padding: var(--cpd-space-3x); padding: var(--cpd-space-4x);
box-sizing: border-box; box-sizing: border-box;
/* changing the color from $separator to transparent as it is /* changing the color from $separator to transparent as it is
the best visual output during the transition period. This will be the best visual output during the transition period. This will be
@ -36,8 +36,13 @@ limitations under the License.
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--cpd-space-2x); gap: var(--cpd-space-3x);
flex-shrink: 0; flex-shrink: 0;
border-block-end: var(--cpd-border-width-1) solid $separator;
.mx_BaseCard_header_spacer {
flex: 1;
}
> h2 { > h2 {
margin: 0 44px; margin: 0 44px;
@ -155,52 +160,6 @@ limitations under the License.
} }
} }
.mx_BaseCard_back,
.mx_BaseCard_close {
flex-shrink: 0;
position: relative;
/* @TODO(kerrya) background colours here are not semantic
these buttons to be replaced with IconButton after secondary variant is added
https://github.com/vector-im/compound/issues/279 */
background-color: var(--cpd-color-bg-subtle-secondary);
width: var(--BaseCard_header-button-size);
height: var(--BaseCard_header-button-size);
border-radius: 50%;
&:hover {
background-color: var(--cpd-color-bg-subtle-primary);
}
&::before {
content: "";
position: absolute;
height: inherit;
width: inherit;
top: 0;
left: 0;
mask-repeat: no-repeat;
mask-position: center;
mask-size: 20px;
background-color: var(--cpd-color-icon-secondary);
}
}
.mx_BaseCard_back {
order: 0; /* always first! */
&::before {
transform: rotate(90deg);
mask-size: 22px;
mask-image: url("$(res)/img/feather-customised/chevron-down.svg");
}
}
.mx_BaseCard_close {
order: 999; /* always last */
&::before {
mask-image: url("@vector-im/compound-design-tokens/icons/close.svg");
}
}
.mx_ContextualMenu_wrapper.mx_BaseCard_header_title { .mx_ContextualMenu_wrapper.mx_BaseCard_header_title {
.mx_ContextualMenu { .mx_ContextualMenu {
position: initial; position: initial;
@ -235,7 +194,3 @@ limitations under the License.
} }
} }
} }
.mx_BaseCard_headerProp {
flex: 1 1 100%;
}

View file

@ -14,7 +14,7 @@ $overlay-background: var(--cpd-color-alpha-gray-1300);
$panels: var(--cpd-color-bg-subtle-secondary); $panels: var(--cpd-color-bg-subtle-secondary);
$panel-actions: var(--cpd-color-alpha-gray-300); $panel-actions: var(--cpd-color-alpha-gray-300);
$separator: var(--cpd-color-alpha-gray-400); $separator: var(--cpd-color-gray-400);
/* ******************** */ /* ******************** */

View file

@ -105,7 +105,7 @@ $overlay-background: rgba($background, 0.85);
$panels: rgba($system, 0.9); $panels: rgba($system, 0.9);
$panel-actions: $roomtile-selected-bg-color; $panel-actions: $roomtile-selected-bg-color;
$separator: var(--cpd-color-alpha-gray-400); $separator: var(--cpd-color-gray-400);
/** /**
* Creating a `semantic` color scale. This will not be needed with the new * Creating a `semantic` color scale. This will not be needed with the new

View file

@ -163,7 +163,7 @@ $overlay-background: rgba($background, 0.85);
$panels: rgba($system, 0.9); $panels: rgba($system, 0.9);
$panel-actions: $roomtile-selected-bg-color; $panel-actions: $roomtile-selected-bg-color;
$separator: var(--cpd-color-alpha-gray-400); $separator: var(--cpd-color-gray-400);
/* Legacy theme backports */ /* Legacy theme backports */

View file

@ -32,7 +32,7 @@ $overlay-background: var(--cpd-color-alpha-gray-1300);
$panels: var(--cpd-color-bg-subtle-secondary); $panels: var(--cpd-color-bg-subtle-secondary);
$panel-actions: var(--cpd-color-alpha-gray-300); $panel-actions: var(--cpd-color-alpha-gray-300);
$separator: var(--cpd-color-alpha-gray-400); $separator: var(--cpd-color-gray-400);
$accent: var(--cpd-color-text-action-accent); $accent: var(--cpd-color-text-action-accent);
$alert: var(--cpd-color-text-critical-primary); $alert: var(--cpd-color-text-critical-primary);

View file

@ -229,7 +229,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
const roomContext = useContext(RoomContext); const roomContext = useContext(RoomContext);
const timelinePanel = useRef<TimelinePanel | null>(null); const timelinePanel = useRef<TimelinePanel | null>(null);
const card = useRef<HTMLDivElement | null>(null); const card = useRef<HTMLDivElement | null>(null);
const closeButonRef = useRef<HTMLDivElement | null>(null); const closeButonRef = useRef<HTMLButtonElement | null>(null);
const [filterOption, setFilterOption] = useState<ThreadFilterType>(ThreadFilterType.All); const [filterOption, setFilterOption] = useState<ThreadFilterType>(ThreadFilterType.All);
const [room, setRoom] = useState<Room | null>(null); const [room, setRoom] = useState<Room | null>(null);

View file

@ -14,12 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { forwardRef, ReactNode, KeyboardEvent, Ref } from "react"; import React, { forwardRef, ReactNode, KeyboardEvent, Ref, MouseEvent } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { IconButton, Text } from "@vector-im/compound-web";
import { Icon as CloseIcon } from "@vector-im/compound-design-tokens/icons/close.svg";
import { Icon as ChevronLeftIcon } from "@vector-im/compound-design-tokens/icons/chevron-left.svg";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { backLabelForPhase } from "../../../stores/right-panel/RightPanelStorePhases"; import { backLabelForPhase } from "../../../stores/right-panel/RightPanelStorePhases";
import { CardContext } from "./context"; import { CardContext } from "./context";
@ -34,13 +36,13 @@ interface IProps {
ariaLabelledBy?: string; ariaLabelledBy?: string;
withoutScrollContainer?: boolean; withoutScrollContainer?: boolean;
closeLabel?: string; closeLabel?: string;
onClose?(ev: ButtonEvent): void; onClose?(ev: MouseEvent<HTMLButtonElement>): void;
onBack?(ev: ButtonEvent): void; onBack?(ev: MouseEvent<HTMLButtonElement>): void;
onKeyDown?(ev: KeyboardEvent): void; onKeyDown?(ev: KeyboardEvent): void;
cardState?: any; cardState?: any;
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
// Ref for the 'close' button the the card // Ref for the 'close' button the the card
closeButtonRef?: Ref<HTMLDivElement>; closeButtonRef?: Ref<HTMLButtonElement>;
children: ReactNode; children: ReactNode;
} }
@ -81,26 +83,39 @@ const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
) => { ) => {
let backButton; let backButton;
const cardHistory = RightPanelStore.instance.roomPhaseHistory; const cardHistory = RightPanelStore.instance.roomPhaseHistory;
if (cardHistory.length > 1) { if (cardHistory.length > 1 && !hideHeaderButtons) {
const prevCard = cardHistory[cardHistory.length - 2]; const prevCard = cardHistory[cardHistory.length - 2];
const onBackClick = (ev: ButtonEvent): void => { const onBackClick = (ev: MouseEvent<HTMLButtonElement>): void => {
onBack?.(ev); onBack?.(ev);
RightPanelStore.instance.popCard(); RightPanelStore.instance.popCard();
}; };
const label = backLabelForPhase(prevCard.phase) ?? _t("action|back"); const label = backLabelForPhase(prevCard.phase) ?? _t("action|back");
backButton = <AccessibleButton className="mx_BaseCard_back" onClick={onBackClick} title={label} />; backButton = (
<IconButton
size="28px"
data-testid="base-card-back-button"
onClick={onBackClick}
tooltip={label}
subtleBackground
>
<ChevronLeftIcon />
</IconButton>
);
} }
let closeButton; let closeButton;
if (onClose) { if (onClose && !hideHeaderButtons) {
closeButton = ( closeButton = (
<AccessibleButton <IconButton
size="28px"
data-testid="base-card-close-button" data-testid="base-card-close-button"
className="mx_BaseCard_close"
onClick={onClose} onClick={onClose}
title={closeLabel || _t("action|close")}
ref={closeButtonRef} ref={closeButtonRef}
/> tooltip={closeLabel ?? _t("action|close")}
subtleBackground
>
<CloseIcon />
</IconButton>
); );
} }
@ -108,16 +123,6 @@ const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
children = <AutoHideScrollbar>{children}</AutoHideScrollbar>; children = <AutoHideScrollbar>{children}</AutoHideScrollbar>;
} }
let headerButtons: React.ReactElement | undefined;
if (!hideHeaderButtons) {
headerButtons = (
<>
{backButton}
{closeButton}
</>
);
}
const shouldRenderHeader = header || !hideHeaderButtons; const shouldRenderHeader = header || !hideHeaderButtons;
return ( return (
@ -132,8 +137,15 @@ const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
> >
{shouldRenderHeader && ( {shouldRenderHeader && (
<div className="mx_BaseCard_header"> <div className="mx_BaseCard_header">
{headerButtons} {backButton}
<div className="mx_BaseCard_headerProp">{header}</div> {typeof header === "string" ? (
<Text size="md" weight="medium" className="mx_BaseCard_header_title">
{header}
</Text>
) : (
header ?? <div className="mx_BaseCard_header_spacer" />
)}
{closeButton}
</div> </div>
)} )}
{children} {children}

View file

@ -1778,7 +1778,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
return ( return (
<BaseCard <BaseCard
className={classes.join(" ")} className={classes.join(" ")}
header={createSpaceScopeHeader(room)} header={createSpaceScopeHeader(room) ?? _t("common|profile")}
onClose={onClose} onClose={onClose}
closeLabel={closeLabel} closeLabel={closeLabel}
cardState={cardState} cardState={cardState}

View file

@ -13,33 +13,36 @@ exports[`AppTile destroys non-persisted right panel widget on room change 1`] =
class="mx_BaseCard_header" class="mx_BaseCard_header"
> >
<div <div
aria-label="Close" class="mx_BaseCard_header_title"
class="mx_AccessibleButton mx_BaseCard_close" >
<h4
class="mx_Heading_h4 mx_BaseCard_header_title_heading"
>
Example 1
</h4>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_BaseCard_header_title_button--option"
role="button"
tabindex="0"
/>
</div>
<button
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
data-testid="base-card-close-button" data-testid="base-card-close-button"
role="button" role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0" tabindex="0"
/>
<div
class="mx_BaseCard_headerProp"
> >
<div <div
class="mx_BaseCard_header_title" class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
> >
<h4 <div />
class="mx_Heading_h4 mx_BaseCard_header_title_heading"
>
Example 1
</h4>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_BaseCard_header_title_button--option"
role="button"
tabindex="0"
/>
</div> </div>
</div> </button>
</div> </div>
<div <div
class="mx_AppTileFullWidth" class="mx_AppTileFullWidth"

View file

@ -77,16 +77,25 @@ exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
<div <div
class="mx_BaseCard_header" class="mx_BaseCard_header"
> >
<div <p
aria-label="Close" class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 mx_BaseCard_header_title"
class="mx_AccessibleButton mx_BaseCard_close" >
Profile
</p>
<button
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
data-testid="base-card-close-button" data-testid="base-card-close-button"
role="button" role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0" tabindex="0"
/> >
<div <div
class="mx_BaseCard_headerProp" class="_indicator-icon_133tf_26"
/> style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</div> </div>
<div <div
class="mx_AutoHideScrollbar" class="mx_AutoHideScrollbar"
@ -232,16 +241,25 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
<div <div
class="mx_BaseCard_header" class="mx_BaseCard_header"
> >
<div <p
aria-label="Close" class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 mx_BaseCard_header_title"
class="mx_AccessibleButton mx_BaseCard_close" >
Profile
</p>
<button
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
data-testid="base-card-close-button" data-testid="base-card-close-button"
role="button" role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0" tabindex="0"
/> >
<div <div
class="mx_BaseCard_headerProp" class="_indicator-icon_133tf_26"
/> style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</div> </div>
<div <div
class="mx_AutoHideScrollbar" class="mx_AutoHideScrollbar"

View file

@ -9,7 +9,7 @@ exports[`<ThirdPartyMemberInfo /> should render invite 1`] = `
class="mx_BaseCard_header" class="mx_BaseCard_header"
> >
<div <div
class="mx_BaseCard_headerProp" class="mx_BaseCard_header_spacer"
/> />
</div> </div>
<div <div
@ -51,7 +51,7 @@ exports[`<ThirdPartyMemberInfo /> should render invite when room in not availabl
class="mx_BaseCard_header" class="mx_BaseCard_header"
> >
<div <div
class="mx_BaseCard_headerProp" class="mx_BaseCard_header_spacer"
/> />
</div> </div>
<div <div

View file

@ -2977,7 +2977,7 @@
dependencies: dependencies:
svg2vectordrawable "^2.9.1" svg2vectordrawable "^2.9.1"
"@vector-im/compound-web@^5.2.3": "@vector-im/compound-web@^5.4.0":
version "5.4.0" version "5.4.0"
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-5.4.0.tgz#b95262197199c11931a8c6f5269514eb9461f187" resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-5.4.0.tgz#b95262197199c11931a8c6f5269514eb9461f187"
integrity sha512-+EPbr8HzlGEWSePEcPs2iQEBnjXvHGWK177SKF8IO2C7Z2Ygddxa2VTQ7oqtrUfgT+NB5IBTLyXV4Nx7FLgmMA== integrity sha512-+EPbr8HzlGEWSePEcPs2iQEBnjXvHGWK177SKF8IO2C7Z2Ygddxa2VTQ7oqtrUfgT+NB5IBTLyXV4Nx7FLgmMA==