From d811dd1ed49464e1469271b4348bdfbe46d003b7 Mon Sep 17 00:00:00 2001
From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
Date: Thu, 25 Jan 2024 15:48:44 -0800
Subject: [PATCH] Username UI Improvements
Co-authored-by: Fedor Indutny <238531+indutny@users.noreply.github.com>
---
_locales/en/messages.json | 4 +
images/signal-qr-logo.svg | 1 -
.../components/EditUsernameModalBody.scss | 22 +-
.../components/UsernameLinkModalBody.scss | 11 +-
ts/components/EditUsernameModalBody.tsx | 2 +-
.../UsernameLinkModalBody.stories.tsx | 15 +-
ts/components/UsernameLinkModalBody.tsx | 438 ++++++++++--------
ts/test-node/util/splitText_test.ts | 59 +++
ts/util/splitText.ts | 46 ++
9 files changed, 379 insertions(+), 219 deletions(-)
delete mode 100644 images/signal-qr-logo.svg
create mode 100644 ts/test-node/util/splitText_test.ts
create mode 100644 ts/util/splitText.ts
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 5f1a9d20c0..6e0a8aaa0a 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -6839,6 +6839,10 @@
"messageformat": "Continue",
"description": "Text of the primary button on username change confirmation modal"
},
+ "icu:UsernameLinkModalBody__hint": {
+ "messageformat": "Scan this QR code with your phone to chat with me on Signal.",
+ "descrption": "Text of the hint displayed below generated QR code on the printable image."
+ },
"icu:UsernameLinkModalBody__save": {
"messageformat": "Save",
"description": "Name of the button for saving username link QR code to disk in the username link modal"
diff --git a/images/signal-qr-logo.svg b/images/signal-qr-logo.svg
deleted file mode 100644
index 51ba45423b..0000000000
--- a/images/signal-qr-logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/stylesheets/components/EditUsernameModalBody.scss b/stylesheets/components/EditUsernameModalBody.scss
index 7345416a64..344aaba813 100644
--- a/stylesheets/components/EditUsernameModalBody.scss
+++ b/stylesheets/components/EditUsernameModalBody.scss
@@ -69,16 +69,20 @@
&__error {
@include font-body-2;
- margin-block: 16px;
- margin-inline: 0;
+ margin-block: 8px 12px;
+ margin-inline: 6px;
+ font-size: 12px;
+ line-height: 17px;
color: $color-accent-red;
}
&__info {
@include font-body-2;
- margin-block: 16px;
- margin-inline: 0;
+ font-size: 12px;
+ line-height: 17px;
+ margin-block: 12px;
+ margin-inline: 6px;
@include light-theme {
color: $color-gray-60;
@@ -87,10 +91,10 @@
color: $color-gray-25;
}
- // To account for missing error section - 16px previous margin, 34px for
- // 16px margin of error plus 18px line height.
+ // To account for missing error section: 8px top margin, 17px line height,
+ // 12px bottom margin.
&--no-error {
- margin-bottom: 50px;
+ margin-bottom: 37px;
}
}
@@ -125,4 +129,8 @@
-webkit-mask: url(../images/icons/v2/hashtag-24.svg) no-repeat center;
}
}
+
+ &__input__container.Input__container {
+ margin-block-end: 8px;
+ }
}
diff --git a/stylesheets/components/UsernameLinkModalBody.scss b/stylesheets/components/UsernameLinkModalBody.scss
index 5d57b8cf2b..11446925ec 100644
--- a/stylesheets/components/UsernameLinkModalBody.scss
+++ b/stylesheets/components/UsernameLinkModalBody.scss
@@ -22,6 +22,7 @@
padding-block: 22px;
padding-inline: 28px;
+ margin-block-start: 8px;
background: var(--bg-color);
border-radius: 18px;
max-width: 204px;
@@ -55,16 +56,6 @@
width: 100%;
}
- &__logo {
- --size: 25px;
- position: absolute;
- top: calc(50% - var(--size) / 2);
- inset-inline-start: calc(50% - var(--size) / 2);
- width: var(--size);
- height: var(--size);
- @include color-svg('../images/signal-qr-logo.svg', var(--fg-color));
- }
-
&__error-icon {
-webkit-mask-size: 100%;
display: block;
diff --git a/ts/components/EditUsernameModalBody.tsx b/ts/components/EditUsernameModalBody.tsx
index efdb658af9..85b3b69770 100644
--- a/ts/components/EditUsernameModalBody.tsx
+++ b/ts/components/EditUsernameModalBody.tsx
@@ -273,7 +273,7 @@ export function EditUsernameModalBody({
= args => {
- {attachment &&
}
+ {attachment && (
+
+ )}
>
);
};
diff --git a/ts/components/UsernameLinkModalBody.tsx b/ts/components/UsernameLinkModalBody.tsx
index e29176cfc1..1adf8e698c 100644
--- a/ts/components/UsernameLinkModalBody.tsx
+++ b/ts/components/UsernameLinkModalBody.tsx
@@ -16,6 +16,7 @@ import type { LocalizerType } from '../types/Util';
import { IMAGE_PNG } from '../types/MIME';
import { strictAssert } from '../util/assert';
import { drop } from '../util/drop';
+import { splitText } from '../util/splitText';
import { Button, ButtonVariant } from './Button';
import { Modal } from './Modal';
import { ConfirmationDialog } from './ConfirmationDialog';
@@ -39,59 +40,95 @@ export type PropsType = Readonly<{
export type ColorMapEntryType = Readonly<{
fg: string;
bg: string;
+ tint: string;
}>;
const ColorEnum = Proto.AccountRecord.UsernameLink.Color;
-const DEFAULT_PRESET: ColorMapEntryType = { fg: '#2449c0', bg: '#506ecd' };
+const DEFAULT_PRESET: ColorMapEntryType = {
+ fg: '#2449c0',
+ bg: '#506ecd',
+ tint: '#ecf0fb',
+};
export const COLOR_MAP: ReadonlyMap = new Map([
[ColorEnum.BLUE, DEFAULT_PRESET],
- [ColorEnum.WHITE, { fg: '#000000', bg: '#ffffff' }],
- [ColorEnum.GREY, { fg: '#464852', bg: '#6a6c74' }],
- [ColorEnum.OLIVE, { fg: '#73694f', bg: '#a89d7f' }],
- [ColorEnum.GREEN, { fg: '#55733f', bg: '#829a6e' }],
- [ColorEnum.ORANGE, { fg: '#d96b2d', bg: '#de7134' }],
- [ColorEnum.PINK, { fg: '#bb617b', bg: '#e67899' }],
- [ColorEnum.PURPLE, { fg: '#7651c5', bg: '#9c84cf' }],
+ [ColorEnum.WHITE, { fg: '#000000', bg: '#ffffff', tint: '#f5f5f5' }],
+ [ColorEnum.GREY, { fg: '#464852', bg: '#6a6c75', tint: '#f0f0f1' }],
+ [ColorEnum.OLIVE, { fg: '#73694f', bg: '#aa9c7c', tint: '#f6f5f2' }],
+ [ColorEnum.GREEN, { fg: '#55733f', bg: '#7c9b69', tint: '#f1f5f0' }],
+ [ColorEnum.ORANGE, { fg: '#d96b2d', bg: '#ee691a', tint: '#fef1ea' }],
+ [ColorEnum.PINK, { fg: '#bb617b', bg: '#f77099', tint: '#fef1f5' }],
+ [ColorEnum.PURPLE, { fg: '#7651c5', bg: '#a183d4', tint: '#f5f3fb' }],
]);
+const LOGO_PATH =
+ 'M16.904 32.723V35a17.034 17.034 0 0 1-5.594-1.334l.595-2.22a14.763 14' +
+ '.763 0 0 0 5 1.277ZM9.119 33.064l.667-2.49-5.707 1.338 1.18-5.034-2.3' +
+ '82.209-1.22 5.204A1.7 1.7 0 0 0 3.7 34.334l5.419-1.27ZM3.28 19.159c.1' +
+ '5 1.91.671 3.77 1.53 5.477l-2.41.21a17.037 17.037 0 0 1-1.397-5.688H3' +
+ '.28ZM3.277 16.885H1c.146-2.223.727-4.4 1.712-6.403l1.972 1.139a14.765' +
+ ' 14.765 0 0 0-1.407 5.264ZM5.821 9.652 3.85 8.513a17.035 17.035 0 0 1' +
+ ' 4.69-4.68l1.138 1.972a14.763 14.763 0 0 0-3.856 3.847ZM11.648 4.672l' +
+ '-1.139-1.973c2-.978 4.172-1.556 6.395-1.699v2.277a14.762 14.762 0 0 0' +
+ '-5.256 1.395ZM19.177 3.283c1.816.145 3.593.625 5.24 1.42l1.137-1.973a' +
+ '17.034 17.034 0 0 0-6.377-1.725v2.278ZM29.795 9.118c.14.186.276.376.4' +
+ '07.568l1.971-1.139a17.035 17.035 0 0 0-4.654-4.675l-1.138 1.973a14.76' +
+ '3 14.763 0 0 1 3.414 3.273ZM32.52 15.322c.096.518.163 1.04.203 1.563H' +
+ '35a17.048 17.048 0 0 0-1.694-6.367l-1.973 1.14c.552 1.16.952 2.391 1.' +
+ '187 3.664ZM32.188 22.09a14.759 14.759 0 0 1-.871 2.287l1.972 1.139a17' +
+ '.032 17.032 0 0 0 1.708-6.357H32.72a14.768 14.768 0 0 1-.532 2.93ZM28' +
+ '.867 27.995a14.757 14.757 0 0 1-2.504 2.173l1.139 1.973a17.028 17.028' +
+ ' 0 0 0 4.65-4.657l-1.972-1.139c-.396.58-.835 1.13-1.313 1.65ZM23.259 ' +
+ '31.797c-1.314.5-2.69.809-4.082.92v2.278a17.033 17.033 0 0 0 6.358-1.7' +
+ '16l-1.139-1.972c-.371.179-.75.342-1.137.49Z M11.66 7.265a12.463 12.46' +
+ '3 0 0 1 11.9-.423 12.466 12.466 0 0 1 6.42 14.612 12.47 12.47 0 0 1-1' +
+ '3.21 8.954 12.462 12.462 0 0 1-5.411-1.857L6.246 29.75l1.199-5.115a12' +
+ '.47 12.47 0 0 1 4.216-17.37Z';
+
const CLASS = 'UsernameLinkModalBody';
const AUTODETECT_TYPE_NUMBER = 0;
const ERROR_CORRECTION_LEVEL = 'H';
-const CENTER_CUTAWAY_PERCENTAGE = 32 / 184;
+const CENTER_CUTAWAY_PERCENTAGE = 30 / 184;
+const CENTER_LOGO_PERCENTAGE = 38 / 184;
+const QR_NATIVE_SIZE = 36;
-const PRINT_WIDTH = 296;
-const DEFAULT_PRINT_HEIGHT = 324;
-const PRINT_SHADOW_BLUR = 4;
-const PRINT_CARD_RADIUS = 24;
-const PRINT_MAX_USERNAME_WIDTH = 222;
-const PRINT_USERNAME_LINE_HEIGHT = 25;
-const PRINT_USERNAME_Y = 269;
+export const PRINT_WIDTH = 424;
+export const PRINT_HEIGHT = 576;
+const PRINT_PIXEL_RATIO = 3;
const PRINT_QR_SIZE = 184;
-const PRINT_QR_Y = 48;
-const PRINT_QR_PADDING = 16;
-const PRINT_QR_PADDING_RADIUS = 12;
-const PRINT_DPI = 224;
-const PRINT_LOGO_SIZE = 36;
+const PRINT_DPI = 300;
+const BASE_PILL_WIDTH = 296;
+const BASE_PILL_HEIGHT = 324;
+const USERNAME_TOP = 352;
+const USERNAME_MAX_WIDTH = 222;
+const USERNAME_LINE_HEIGHT = 26;
+const USERNAME_FONT = `600 20px/${USERNAME_LINE_HEIGHT}px Inter`;
+const USERNAME_LETTER_SPACING = -0.34;
+
+const HINT_BASE_TOP = 447;
+const HINT_MAX_WIDTH = 296;
+const HINT_LINE_HEIGHT = 17;
+const HINT_FONT = `400 14px/${HINT_LINE_HEIGHT}px Inter`;
+const HINT_LETTER_SPACING = 0;
type BlotchesPropsType = Readonly<{
- className?: string;
+ size: number;
link: string;
color: string;
}>;
-function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element {
+function QRCode({ size, link, color }: BlotchesPropsType): JSX.Element {
const qr = QR(AUTODETECT_TYPE_NUMBER, ERROR_CORRECTION_LEVEL);
qr.addData(link);
qr.make();
- const size = qr.getModuleCount();
- const center = size / 2;
- const radius = CENTER_CUTAWAY_PERCENTAGE * size;
+ const moduleCount = qr.getModuleCount();
+ const center = moduleCount / 2;
+ const radius = CENTER_CUTAWAY_PERCENTAGE * moduleCount;
function hasPixel(x: number, y: number): boolean {
- if (x < 0 || y < 0 || x >= size || y >= size) {
+ if (x < 0 || y < 0 || x >= moduleCount || y >= moduleCount) {
return false;
}
@@ -100,7 +137,7 @@ function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element {
);
// Center and 1 dot away should remain clear for the logo placement.
- if (Math.ceil(distanceFromCenter) <= radius + 2) {
+ if (Math.ceil(distanceFromCenter) <= radius + 3) {
return false;
}
@@ -108,8 +145,8 @@ function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element {
}
const path = [];
- for (let y = 0; y < size; y += 1) {
- for (let x = 0; x < size; x += 1) {
+ for (let y = 0; y < moduleCount; y += 1) {
+ for (let x = 0; x < moduleCount; x += 1) {
if (!hasPixel(x, y)) {
continue;
}
@@ -135,21 +172,89 @@ function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element {
}
}
+ const QR_SCALE = size / 2 / moduleCount;
+
+ const CENTER_X = size / 2;
+ const CENTER_Y = size / 2;
+ const LOGO_SIZE = CENTER_LOGO_PERCENTAGE * size;
+ const LOGO_X = CENTER_X - LOGO_SIZE / 2;
+ const LOGO_Y = CENTER_Y - LOGO_SIZE / 2;
+ const LOGO_SCALE = LOGO_SIZE / QR_NATIVE_SIZE;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+type ExportedImagePropsType = Readonly<{
+ link: string;
+ colorId: number;
+ usernameLines: number;
+}>;
+
+function ExportedImage({
+ link,
+ colorId,
+ usernameLines,
+}: ExportedImagePropsType): JSX.Element {
+ const { fg, bg, tint } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET;
+
+ const isWhiteBackground = colorId === ColorEnum.WHITE;
+
+ const extraHeight = (usernameLines - 1) * USERNAME_LINE_HEIGHT;
+ const pillHeight = BASE_PILL_HEIGHT + extraHeight;
+
return (
);
}
@@ -157,39 +262,29 @@ function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element {
type CreateCanvasAndContextOptionsType = Readonly<{
width: number;
height: number;
- devicePixelRatio?: number;
}>;
function createCanvasAndContext({
width,
height,
- devicePixelRatio = window.devicePixelRatio,
}: CreateCanvasAndContextOptionsType): [
OffscreenCanvas,
OffscreenCanvasRenderingContext2D
] {
const canvas = new OffscreenCanvas(
- devicePixelRatio * width,
- devicePixelRatio * height
+ PRINT_PIXEL_RATIO * width,
+ PRINT_PIXEL_RATIO * height
);
const context = canvas.getContext('2d');
strictAssert(context, 'Failed to get 2d context');
// Retina support
- context.scale(devicePixelRatio, devicePixelRatio);
+ context.scale(PRINT_PIXEL_RATIO, PRINT_PIXEL_RATIO);
- // Font config
- context.font = `600 20px/${PRINT_USERNAME_LINE_HEIGHT}px Inter`;
+ // Common font config
context.textAlign = 'center';
context.textBaseline = 'top';
-
- // Experimental Chrome APIs
- (
- context as unknown as {
- letterSpacing: number;
- }
- ).letterSpacing = -0.34;
(
context as unknown as {
textRendering: string;
@@ -201,155 +296,74 @@ function createCanvasAndContext({
return [canvas, context];
}
-type GetLogoCanvasOptionsType = Readonly<{
- fgColor: string;
- imageUrl?: string;
- devicePixelRatio?: number;
+type CreateTextMeasurerOptionsType = Readonly<{
+ font: string;
+ letterSpacing: number;
+ maxWidth: number;
}>;
-async function getLogoCanvas({
- fgColor,
- imageUrl = 'images/signal-qr-logo.svg',
- devicePixelRatio,
-}: GetLogoCanvasOptionsType): Promise {
- const img = new Image();
- await new Promise((resolve, reject) => {
- img.addEventListener('load', resolve);
- img.addEventListener('error', () =>
- reject(new Error('Failed to load image'))
- );
- img.src = imageUrl;
- });
-
- const [canvas, context] = createCanvasAndContext({
- width: PRINT_LOGO_SIZE,
- height: PRINT_LOGO_SIZE,
- devicePixelRatio,
- });
-
- context.fillStyle = fgColor;
- context.fillRect(0, 0, PRINT_LOGO_SIZE, PRINT_LOGO_SIZE);
- context.globalCompositeOperation = 'destination-in';
- context.drawImage(img, 0, 0, PRINT_LOGO_SIZE, PRINT_LOGO_SIZE);
-
- return canvas;
-}
-
-function splitUsername(username: string): Array {
- const result = new Array();
-
+function createTextMeasurer({
+ font,
+ letterSpacing,
+ maxWidth,
+}: CreateTextMeasurerOptionsType): (text: string) => boolean {
const [, context] = createCanvasAndContext({ width: 1, height: 1 });
- // Compute number of lines and height of username
- for (let i = 0, last = 0; i < username.length; i += 1) {
- const part = username.slice(last, i);
- if (context.measureText(part).width > PRINT_MAX_USERNAME_WIDTH) {
- result.push(username.slice(last, i - 1));
- last = i - 1;
- } else if (i === username.length - 1) {
- result.push(username.slice(last));
+ context.font = font;
+ // Experimental Chrome APIs
+ (
+ context as unknown as {
+ letterSpacing: number;
}
- }
+ ).letterSpacing = letterSpacing;
- return result;
+ return value => context.measureText(value).width > maxWidth;
}
type GenerateImageURLOptionsType = Readonly<{
link: string;
username: string;
+ hint: string;
colorId: number;
- bgColor: string;
- fgColor: string;
-
- // For testing
- logoUrl?: string;
- devicePixelRatio?: number;
}>;
// Exported for testing
export async function _generateImageBlob({
link,
username,
+ hint,
colorId,
- bgColor,
- fgColor,
- logoUrl,
- devicePixelRatio,
}: GenerateImageURLOptionsType): Promise {
- const usernameLines = splitUsername(username);
- const usernameHeight = PRINT_USERNAME_LINE_HEIGHT * usernameLines.length;
-
- const isWhiteBackground = colorId === ColorEnum.WHITE;
-
- const padding = isWhiteBackground ? PRINT_SHADOW_BLUR : 0;
-
- const totalHeight =
- DEFAULT_PRINT_HEIGHT - PRINT_USERNAME_LINE_HEIGHT + usernameHeight;
- const [canvas, context] = createCanvasAndContext({
- width: PRINT_WIDTH + 2 * padding,
- height: totalHeight + 2 * padding,
- devicePixelRatio,
+ const usernameLines = splitText(username, {
+ granularity: 'grapheme',
+ shouldBreak: createTextMeasurer({
+ maxWidth: USERNAME_MAX_WIDTH,
+ font: USERNAME_FONT,
+ letterSpacing: USERNAME_LETTER_SPACING,
+ }),
});
- // Draw card
- context.save();
- if (isWhiteBackground) {
- context.shadowColor = 'rgba(0, 0, 0, 0.08)';
- context.shadowBlur = PRINT_SHADOW_BLUR;
- }
- context.fillStyle = bgColor;
- context.beginPath();
- context.roundRect(
- padding,
- padding,
- PRINT_WIDTH,
- totalHeight,
- PRINT_CARD_RADIUS
+ const hintLines = splitText(hint, {
+ granularity: 'word',
+ shouldBreak: createTextMeasurer({
+ maxWidth: HINT_MAX_WIDTH,
+ font: HINT_FONT,
+ letterSpacing: HINT_LETTER_SPACING,
+ }),
+ });
+
+ const [canvas, context] = createCanvasAndContext({
+ width: PRINT_WIDTH,
+ height: PRINT_HEIGHT,
+ });
+
+ const svg = renderToStaticMarkup(
+
);
- context.fill();
- context.restore();
-
- // Draw padding around QR code
- context.save();
- context.fillStyle = '#fff';
- const sizeWithPadding = PRINT_QR_SIZE + 2 * PRINT_QR_PADDING;
- context.beginPath();
- context.roundRect(
- padding + (PRINT_WIDTH - sizeWithPadding) / 2,
- padding + PRINT_QR_Y - PRINT_QR_PADDING,
- sizeWithPadding,
- sizeWithPadding,
- PRINT_QR_PADDING_RADIUS
- );
- context.fill();
- if (isWhiteBackground) {
- context.lineWidth = 2;
- context.strokeStyle = '#e9e9e9';
- context.stroke();
- }
- context.restore();
-
- // Draw username
- context.fillStyle = isWhiteBackground ? '#000' : '#fff';
- for (const [i, line] of usernameLines.entries()) {
- context.fillText(
- line,
- padding + PRINT_WIDTH / 2,
- PRINT_USERNAME_Y + i * PRINT_USERNAME_LINE_HEIGHT
- );
- }
-
- // Draw logo
- context.drawImage(
- await getLogoCanvas({ fgColor, imageUrl: logoUrl, devicePixelRatio }),
- padding + (PRINT_WIDTH - PRINT_LOGO_SIZE) / 2,
- padding + PRINT_QR_Y + (PRINT_QR_SIZE - PRINT_LOGO_SIZE) / 2,
- PRINT_LOGO_SIZE,
- PRINT_LOGO_SIZE
- );
-
- // Draw QR code
- const svg = renderToStaticMarkup(Blotches({ link, color: fgColor }));
const svgURL = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
const img = new Image();
@@ -361,13 +375,42 @@ export async function _generateImageBlob({
img.src = svgURL;
});
- context.drawImage(
- img,
- padding + (PRINT_WIDTH - PRINT_QR_SIZE) / 2,
- PRINT_QR_Y + padding,
- PRINT_QR_SIZE,
- PRINT_QR_SIZE
- );
+ context.drawImage(img, 0, 0, PRINT_WIDTH, PRINT_HEIGHT);
+
+ const isWhiteBackground = colorId === ColorEnum.WHITE;
+
+ context.save();
+ context.font = USERNAME_FONT;
+ // Experimental Chrome APIs
+ (
+ context as unknown as {
+ letterSpacing: number;
+ }
+ ).letterSpacing = USERNAME_LETTER_SPACING;
+ context.fillStyle = isWhiteBackground ? '#000' : '#fff';
+
+ const centerX = PRINT_WIDTH / 2;
+ for (const [i, line] of usernameLines.entries()) {
+ context.fillText(line, centerX, USERNAME_TOP + i * USERNAME_LINE_HEIGHT);
+ }
+ context.restore();
+
+ context.save();
+ context.font = HINT_FONT;
+ // Experimental Chrome APIs
+ (
+ context as unknown as {
+ letterSpacing: number;
+ }
+ ).letterSpacing = HINT_LETTER_SPACING;
+ context.fillStyle = 'rgba(60, 60, 69, 0.70)';
+
+ const hintTop =
+ HINT_BASE_TOP + (usernameLines.length - 1) * USERNAME_LINE_HEIGHT;
+ for (const [i, line] of hintLines.entries()) {
+ context.fillText(line, centerX, hintTop + i * HINT_LINE_HEIGHT);
+ }
+ context.restore();
const blob = await canvas.convertToBlob({ type: 'image/png' });
return changeDpiBlob(blob, PRINT_DPI);
@@ -533,8 +576,7 @@ export function UsernameLinkModalBody({
link,
username,
colorId,
- bgColor,
- fgColor,
+ hint: i18n('icu:UsernameLinkModalBody__hint'),
});
const arrayBuffer = await blob.arrayBuffer();
if (isAborted) {
@@ -548,7 +590,7 @@ export function UsernameLinkModalBody({
return () => {
isAborted = true;
};
- }, [link, username, colorId, bgColor, fgColor]);
+ }, [i18n, link, username, colorId, bgColor, fgColor]);
const onSave = useCallback(
(e: React.MouseEvent) => {
@@ -714,14 +756,14 @@ export function UsernameLinkModalBody({
let linkImage: JSX.Element | undefined;
if (usernameLinkState === UsernameLinkState.Ready && link) {
linkImage = (
- <>
-
-
- >
+
);
} else if (usernameLinkState === UsernameLinkState.Error) {
linkImage = ;
diff --git a/ts/test-node/util/splitText_test.ts b/ts/test-node/util/splitText_test.ts
new file mode 100644
index 0000000000..3a64a00967
--- /dev/null
+++ b/ts/test-node/util/splitText_test.ts
@@ -0,0 +1,59 @@
+// Copyright 2024 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import type { SplitTextOptionsType } from '../../util/splitText';
+import { splitText } from '../../util/splitText';
+
+describe('splitText', () => {
+ describe('grapheme granularity', () => {
+ const options: SplitTextOptionsType = {
+ granularity: 'grapheme',
+ shouldBreak: x => x.length > 6,
+ };
+
+ it('splits text into one line', () => {
+ assert.deepEqual(splitText('signal', options), ['signal']);
+ });
+
+ it('splits text into two lines', () => {
+ assert.deepEqual(splitText('signal.0123', options), ['signal', '.0123']);
+ });
+
+ it('splits text into three lines', () => {
+ assert.deepEqual(splitText('signal.01234567', options), [
+ 'signal',
+ '.01234',
+ '567',
+ ]);
+ });
+ });
+
+ describe('word granularity', () => {
+ const options: SplitTextOptionsType = {
+ granularity: 'word',
+ shouldBreak: x => x.length > 6,
+ };
+
+ it('splits text into one line', () => {
+ assert.deepEqual(splitText('signal', options), ['signal']);
+ });
+
+ it('splits text into two lines', () => {
+ assert.deepEqual(splitText('signal.0123', options), ['signal.', '0123']);
+ });
+
+ it('splits text into three lines', () => {
+ assert.deepEqual(splitText('aaaaaa b b ccccc', options), [
+ 'aaaaaa',
+ 'b b',
+ 'ccccc',
+ ]);
+ });
+
+ it('trims lines', () => {
+ assert.deepEqual(splitText('signa 0123', options), ['signa', '0123']);
+ });
+ });
+});
diff --git a/ts/util/splitText.ts b/ts/util/splitText.ts
new file mode 100644
index 0000000000..4306d42ebb
--- /dev/null
+++ b/ts/util/splitText.ts
@@ -0,0 +1,46 @@
+// Copyright 2024 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+export type SplitTextOptionsType = Readonly<{
+ granularity: 'grapheme' | 'word';
+ shouldBreak: (slice: string) => boolean;
+}>;
+
+export function splitText(
+ text: string,
+ { granularity, shouldBreak }: SplitTextOptionsType
+): Array {
+ const isWordBased = granularity === 'word';
+ const segmenter = new Intl.Segmenter(undefined, {
+ granularity,
+ });
+
+ const result = new Array();
+
+ // Compute number of lines and height of text
+ let acc = '';
+ let best = '';
+ for (const { segment, isWordLike } of segmenter.segment(text)) {
+ acc += segment;
+
+ // For "grapheme" segmenting, "isWordLike" is always "undefined"
+ if (isWordLike === false) {
+ best = acc;
+ continue;
+ }
+
+ if (shouldBreak(isWordBased ? acc.trim() : acc)) {
+ result.push(best);
+ acc = acc.slice(best.length);
+ best = acc;
+ } else {
+ best = acc;
+ }
+ }
+
+ if (best) {
+ result.push(best);
+ }
+
+ return isWordBased ? result.map(x => x.trim()) : result;
+}