Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers
/playwright/e2e/crypto/ @element-hq/element-crypto-web-reviewers
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
/packages/shared-components/src/crypto/ @element-hq/element-crypto-web-reviewers


/src/models/Call.ts @element-hq/element-call-reviewers
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@
"@matrix-org/analytics-events": "^0.31.0",
"@matrix-org/emojibase-bindings": "^1.5.0",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^10.0.0",
"@types/png-chunks-extract": "^1.0.2",
"@vector-im/compound-design-tokens": "6.9.0",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/shared-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
},
"dependencies": {
"@element-hq/element-web-module-api": "^1.8.0",
"@matrix-org/spec": "^1.7.0",
"@vector-im/compound-design-tokens": "^6.4.3",
"classnames": "^2.5.1",
"counterpart": "^0.18.6",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2026 Element Creations 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.
*/

.container {
display: flex;
flex-wrap: wrap;
gap: var(--cpd-space-2x);
justify-content: space-evenly;
}

.segment {
display: inline-block;
margin-bottom: var(--cpd-space-4x);
text-align: center;
/* Allow maximum 4 per line, accounting for 8px gap */
min-width: calc(25% - 8px);
}

.emoji {
/* Use the Twemoji font for consistency with other clients */
font-family: Twemoji, var(--cpd-font-family-sans);
font-size: var(--cpd-font-size-heading-xl);
}

.label {
font-weight: var(--cpd-font-weight-regular);
font-size: var(--cpd-font-size-body-lg);
color: var(--cpd-color-text-secondary);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { type Meta, type StoryObj } from "@storybook/react-vite";

import { SasEmoji } from "./SasEmoji";

const meta = {
title: "Crypto/SasEmoji",
component: SasEmoji,
tags: ["autodocs"],
args: {
emoji: ["🍕", "🌽", "🚀", "🔒", "🔧", "🍓", "⌛"],
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/XLWIAB5n8yObYvU0INKPK1/Verification-by-Emoji?node-id=1-2935&t=NrV9JnuItrAyyh53-4",
},
},
} satisfies Meta<typeof SasEmoji>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const WorstCaseAlbanian: Story = {
globals: {
language: "sq",
},
args: {
emoji: ["🎅", "🎅", "🎅", "🎅", "🎅", "🎅", "🎅"],
},
};

export const WorstCaseGerman: Story = {
globals: {
language: "de",
},
args: {
emoji: ["🔧", "🔧", "🔧", "🔧", "🔧", "🔧", "🔧"],
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import React from "react";
import { describe, it, expect } from "vitest";
import { render } from "@test-utils";

import { SasEmoji } from "./SasEmoji";

describe("<SasEmoji/>", () => {
it("should match snapshot", () => {
const { asFragment } = render(<SasEmoji emoji={["🦋", "🍄", "⚽", "🌏", "🦄", "🚀", "🔧"]} />);
expect(asFragment()).toMatchSnapshot();
});
});
43 changes: 43 additions & 0 deletions packages/shared-components/src/crypto/SasEmoji/SasEmoji.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React, { type JSX } from "react";
import classNames from "classnames";

import { type SasEmoji, tEmoji } from "./SasEmojiTranslate.ts";
import styles from "./SasEmoji.module.css";
import { useI18n } from "../../utils/i18nContext.ts";

export type Props = {
/**
* The emoji to render
*/
emoji: [SasEmoji, SasEmoji, SasEmoji, SasEmoji, SasEmoji, SasEmoji, SasEmoji];
/**
* Optional className to apply to the container
*/
className?: string;
};

/**
* Renders the 7 emoji used for SAS verification.
* The component is responsive so can be rendered in any context, dialog, side panel.
*/
export function SasEmoji({ emoji, className }: Props): JSX.Element {
const { language } = useI18n();

const emojiBlocks = emoji.map((emoji, i) => (
<div className={styles.segment} key={i}>
<div className={styles.emoji} aria-hidden={true}>
{emoji}
</div>
<div className={styles.label}>{tEmoji(emoji, language)}</div>
</div>
));

return <div className={classNames(styles.container, className)}>{emojiBlocks}</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { describe, it, expect } from "vitest";

import { tEmoji, type SasEmoji } from "./SasEmojiTranslate.ts";

describe("tEmoji", () => {
it.each([
["🐶", "en-GB", "Dog"],
["🐶", "en", "Dog"],
["🐶", "de-DE", "Hund"],
["🐶", "pt", "Cachorro"],
["🔧", "de-DE", "Schraubenschlüssel"],
["🎅", "sq", "Babagjyshi i Vitit të Ri"],
] as [emoji: SasEmoji, locale: string, expectation: string][])(
"should handle locale %s",
(emoji, locale, expectation) => {
expect(tEmoji(emoji, locale)).toEqual(expectation);
},
);
});
122 changes: 122 additions & 0 deletions packages/shared-components/src/crypto/SasEmoji/SasEmojiTranslate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright 2026 Element Creations 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 SasEmojiJson from "@matrix-org/spec/sas-emoji.json";
import { getNormalizedLanguageKeys } from "matrix-web-i18n";

// Type as specified in https://spec.matrix.org/v1.17/client-server-api/#sas-method-emoji
export type SasEmoji =
| "🐶"
| "🐱"
| "🦁"
| "🐎"
| "🦄"
| "🐷"
| "🐘"
| "🐰"
| "🐼"
| "🐓"
| "🐧"
| "🐢"
| "🐟"
| "🐙"
| "🦋"
| "🌷"
| "🌳"
| "🌵"
| "🍄"
| "🌏"
| "🌙"
| "☁"
| "🔥"
| "🍌"
| "🍎"
| "🍓"
| "🌽"
| "🍕"
| "🎂"
| "❤"
| "😀"
| "🤖"
| "🎩"
| "👓"
| "🔧"
| "🎅"
| "👍"
| "☂"
| "⌛"
| "⏰"
| "🎁"
| "💡"
| "📕"
| "✏"
| "📎"
| "✂"
| "🔒"
| "🔑"
| "🔨"
| "☎"
| "🏁"
| "🚂"
| "🚲"
| "✈"
| "🚀"
| "🏆"
| "⚽"
| "🎸"
| "🎺"
| "🔔"
| "⚓"
| "🎧"
| "📁"
| "📌";

const SasEmojiMap = new Map<
SasEmoji,
[
description: string,
translations: {
[normalizedLanguageKey: string]: string;
},
]
>(
SasEmojiJson.map(({ emoji, description, translated_descriptions: translations }) => [
emoji as SasEmoji,
[
description,
// Normalize the translation keys
Object.keys(translations).reduce<Record<string, string>>((o, k) => {
for (const key of getNormalizedLanguageKeys(k)) {
o[key] = translations[k as keyof typeof translations]!;
}
return o;
}, {}),
],
]),
);

/**
* Translate given SAS emoji into the target locale
* @param emoji - the SAS emoji to translate
* @param locale - the BCP 47 locale to translate to, will fall back to English as the base locale for Matrix SAS Emoji.
*/
export function tEmoji(emoji: SasEmoji, locale: string): string {
const mapping = SasEmojiMap.get(emoji);
if (!mapping) {
throw new Error(`Emoji mapping not found for emoji ${emoji}`);
}

const [description, translations] = mapping;

for (const key of getNormalizedLanguageKeys(locale)) {
if (translations[key]) {
return translations[key];
}
}

return description;
}
Loading
Loading