diff --git a/extensions/line/src/message-cards.test.ts b/extensions/line/src/message-cards.test.ts index b46c12bb9314..fca4d70d6690 100644 --- a/extensions/line/src/message-cards.test.ts +++ b/extensions/line/src/message-cards.test.ts @@ -23,6 +23,8 @@ import { messageAction, } from "./template-messages.js"; +const loneHighSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/; + describe("createConfirmTemplate", () => { it("truncates text to 240 characters", () => { const longText = "x".repeat(300); @@ -30,6 +32,17 @@ describe("createConfirmTemplate", () => { expect((template.template as { text: string }).text.length).toBe(240); }); + + it("drops a surrogate-pair emoji from fallback altText instead of splitting it", () => { + const template = createConfirmTemplate( + `${"x".repeat(399)}πŸ˜€`, + messageAction("Yes"), + messageAction("No"), + ); + + expect(template.altText).toBe("x".repeat(399)); + expect(loneHighSurrogate.test(template.altText)).toBe(false); + }); }); describe("createButtonTemplate", () => { @@ -47,6 +60,25 @@ describe("createButtonTemplate", () => { expect((template.template as { title: string }).title.length).toBe(40); }); + it("drops a surrogate-pair emoji from the title instead of splitting it", () => { + // 39 chars + an emoji land the truncation boundary inside the surrogate pair; + // a raw code-unit slice would keep only the lone high surrogate. + const template = createButtonTemplate(`${"x".repeat(39)}πŸ˜€`, "Text", [messageAction("OK")]); + const title = (template.template as { title: string }).title; + + expect(title).toBe("x".repeat(39)); + expect(loneHighSurrogate.test(title)).toBe(false); + }); + + it("drops a surrogate-pair emoji from explicit altText instead of splitting it", () => { + const template = createButtonTemplate("Title", "Text", [messageAction("OK")], { + altText: `${"x".repeat(399)}πŸ˜€`, + }); + + expect(template.altText).toBe("x".repeat(399)); + expect(loneHighSurrogate.test(template.altText)).toBe(false); + }); + it("truncates text to 60 chars when no thumbnail is provided", () => { const longText = "x".repeat(100); const template = createButtonTemplate("Title", longText, [messageAction("OK")]); @@ -98,6 +130,17 @@ describe("createCarouselColumn", () => { expect(column.text.length).toBe(60); }); + it("drops a surrogate-pair emoji from the title instead of splitting it", () => { + const column = createCarouselColumn({ + title: `${"x".repeat(39)}πŸ˜€`, + text: "Text", + actions: [messageAction("OK")], + }); + + expect(column.title).toBe("x".repeat(39)); + expect(loneHighSurrogate.test(column.title ?? "")).toBe(false); + }); + it("does not split an emoji grapheme at the 60-code-unit boundary", () => { const text = `${"x".repeat(59)}πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦after`; const column = createCarouselColumn({ @@ -165,6 +208,16 @@ describe("carousel column limits", () => { const template = createTemplate(); expect((template.template as { columns: unknown[] }).columns.length).toBe(10); }); + + it("drops a surrogate-pair emoji from image-carousel altText instead of splitting it", () => { + const template = createImageCarousel( + [createImageCarouselColumn("https://example.com/0.jpg", messageAction("View"))], + `${"x".repeat(399)}πŸ˜€`, + ); + + expect(template.altText).toBe("x".repeat(399)); + expect(loneHighSurrogate.test(template.altText)).toBe(false); + }); }); describe("createProductCarousel", () => { diff --git a/extensions/line/src/template-messages.ts b/extensions/line/src/template-messages.ts index 9e5060aba4b6..8ef8dd9ed680 100644 --- a/extensions/line/src/template-messages.ts +++ b/extensions/line/src/template-messages.ts @@ -65,6 +65,13 @@ function truncateTemplateText(text: string, limit: number): string { return result; } +function truncateOptionalTemplateText( + value: string | undefined, + limit: number, +): string | undefined { + return value === undefined ? undefined : truncateTemplateText(value, limit); +} + function formatProductCarouselText(description: string, price?: string): string { if (!price) { return description; @@ -86,13 +93,13 @@ export function createConfirmTemplate( ): TemplateMessage { const template: ConfirmTemplate = { type: "confirm", - text: text.slice(0, 240), // LINE limit + text: truncateTemplateText(text, 240), // LINE limit actions: [confirmAction, cancelAction], }; return { type: "template", - altText: altText?.slice(0, 400) ?? text.slice(0, 400), + altText: truncateOptionalTemplateText(altText, 400) ?? truncateTemplateText(text, 400), template, }; } @@ -120,7 +127,7 @@ export function createButtonTemplate( }); const template: ButtonsTemplate = { type: "buttons", - title: title.slice(0, 40), // LINE limit + title: truncateTemplateText(title, 40), // LINE limit text: truncateTemplateText(text, textLimit), actions: actions.slice(0, 4), // LINE limit: max 4 actions thumbnailImageUrl: options?.thumbnailImageUrl, @@ -132,7 +139,9 @@ export function createButtonTemplate( return { type: "template", - altText: options?.altText?.slice(0, 400) ?? `${title}: ${text}`.slice(0, 400), + altText: + truncateOptionalTemplateText(options?.altText, 400) ?? + truncateTemplateText(`${title}: ${text}`, 400), template, }; } @@ -157,7 +166,7 @@ export function createTemplateCarousel( return { type: "template", - altText: options?.altText?.slice(0, 400) ?? "View carousel", + altText: truncateOptionalTemplateText(options?.altText, 400) ?? "View carousel", template, }; } @@ -179,7 +188,7 @@ export function createCarouselColumn(params: { // the buttons template already applies above. const textLimit = resolveTemplateTextLimit({ ...params, textOnlyLimit: 120 }); return { - title: params.title?.slice(0, 40), + title: truncateOptionalTemplateText(params.title, 40), text: truncateTemplateText(params.text, textLimit), actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column thumbnailImageUrl: params.thumbnailImageUrl, @@ -202,7 +211,7 @@ export function createImageCarousel( return { type: "template", - altText: altText?.slice(0, 400) ?? "View images", + altText: truncateOptionalTemplateText(altText, 400) ?? "View images", template, }; }