diff --git a/extensions/line/src/message-cards.test.ts b/extensions/line/src/message-cards.test.ts index db9b216e2d1a..538c7584b4d8 100644 --- a/extensions/line/src/message-cards.test.ts +++ b/extensions/line/src/message-cards.test.ts @@ -51,13 +51,13 @@ describe("createButtonTemplate", () => { expect((template.template as { text: string }).text.length).toBe(60); }); - it("keeps longer text when thumbnail is provided", () => { + it("truncates text to 60 chars when title and thumbnail are provided", () => { const longText = "x".repeat(100); const template = createButtonTemplate("Title", longText, [messageAction("OK")], { thumbnailImageUrl: "https://example.com/thumb.jpg", }); - expect((template.template as { text: string }).text.length).toBe(100); + expect((template.template as { text: string }).text.length).toBe(60); }); }); @@ -77,12 +77,67 @@ describe("createCarouselColumn", () => { expect(column.actions.length).toBe(3); }); - it("truncates text to 120 characters", () => { + it("truncates text to 120 characters when no title or image is set", () => { const longText = "x".repeat(150); const column = createCarouselColumn({ text: longText, actions: [messageAction("OK")] }); expect(column.text.length).toBe(120); }); + + it("truncates text to 60 characters when a title is set", () => { + const longText = "x".repeat(150); + const column = createCarouselColumn({ + title: "Title", + text: longText, + actions: [messageAction("OK")], + }); + + expect(column.text.length).toBe(60); + }); + + it("does not split an emoji grapheme at the 60-code-unit boundary", () => { + const text = `${"x".repeat(59)}πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦after`; + const column = createCarouselColumn({ + title: "Title", + text, + actions: [messageAction("OK")], + }); + + expect(column.text).toBe("x".repeat(59)); + }); + + it("keeps required text when the first grapheme exceeds the limit", () => { + const text = `πŸ˜€${"\u0301".repeat(59)}`; + const column = createCarouselColumn({ + title: "Title", + text, + actions: [messageAction("OK")], + }); + + expect(column.text.length).toBe(60); + expect(column.text.startsWith("πŸ˜€")).toBe(true); + }); + + it("uses the compact limit when a whitespace-only title is present", () => { + const column = createCarouselColumn({ + title: " ", + text: "x".repeat(150), + actions: [messageAction("OK")], + }); + + expect(column.text).toBe("x".repeat(60)); + }); + + it("truncates text to 60 characters when a thumbnail image is set", () => { + const longText = "x".repeat(150); + const column = createCarouselColumn({ + text: longText, + thumbnailImageUrl: "https://example.com/thumb.jpg", + actions: [messageAction("OK")], + }); + + expect(column.text.length).toBe(60); + }); }); describe("carousel column limits", () => { @@ -131,6 +186,20 @@ describe("createProductCarousel", () => { .columns; expect(columns[0].actions[0].type).toBe(expectedType); }); + + it("preserves the complete price when truncating a long description", () => { + const template = createProductCarousel([ + { + title: "Product", + description: "x".repeat(59), + price: "$12.99", + }, + ]); + const columns = (template.template as { columns: Array<{ text: string }> }).columns; + + expect(columns[0].text).toBe(`${"x".repeat(53)}\n$12.99`); + expect(columns[0].text.length).toBe(60); + }); }); describe("flex cards", () => { diff --git a/extensions/line/src/template-messages.ts b/extensions/line/src/template-messages.ts index 357bf3bed281..9e5060aba4b6 100644 --- a/extensions/line/src/template-messages.ts +++ b/extensions/line/src/template-messages.ts @@ -13,6 +13,9 @@ type CarouselColumn = messagingApi.CarouselColumn; type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate; type ImageCarouselColumn = messagingApi.ImageCarouselColumn; +const COMPACT_TEMPLATE_TEXT_LIMIT = 60; +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + type TemplatePayloadAction = { type?: "uri" | "postback" | "message"; uri?: string; @@ -30,6 +33,48 @@ function buildTemplatePayloadAction(action: TemplatePayloadAction): Action { return messageAction(action.label, action.data ?? action.label); } +function resolveTemplateTextLimit(params: { + title?: string; + thumbnailImageUrl?: string; + textOnlyLimit: number; +}): number { + return params.title !== undefined || params.thumbnailImageUrl !== undefined + ? COMPACT_TEMPLATE_TEXT_LIMIT + : params.textOnlyLimit; +} + +function truncateTemplateText(text: string, limit: number): string { + let result = ""; + for (const { segment } of graphemeSegmenter.segment(text)) { + if (result.length + segment.length > limit) { + // A pathological grapheme can exceed LINE's whole field limit. Preserve + // graphemes normally, but keep required text non-empty without splitting + // a surrogate pair when the first grapheme alone cannot fit. + if (!result) { + for (const codePoint of segment) { + if (result.length + codePoint.length > limit) { + break; + } + result += codePoint; + } + } + break; + } + result += segment; + } + return result; +} + +function formatProductCarouselText(description: string, price?: string): string { + if (!price) { + return description; + } + const priceText = truncateTemplateText(price, COMPACT_TEMPLATE_TEXT_LIMIT); + const descriptionLimit = Math.max(0, COMPACT_TEMPLATE_TEXT_LIMIT - priceText.length - 1); + const descriptionText = truncateTemplateText(description, descriptionLimit); + return descriptionText ? `${descriptionText}\n${priceText}` : priceText; +} + /** * Create a confirm template (yes/no style dialog) */ @@ -68,12 +113,15 @@ export function createButtonTemplate( altText?: string; }, ): TemplateMessage { - const hasThumbnail = Boolean(options?.thumbnailImageUrl?.trim()); - const textLimit = hasThumbnail ? 160 : 60; + const textLimit = resolveTemplateTextLimit({ + title, + thumbnailImageUrl: options?.thumbnailImageUrl, + textOnlyLimit: 160, + }); const template: ButtonsTemplate = { type: "buttons", title: title.slice(0, 40), // LINE limit - text: text.slice(0, textLimit), // LINE limit (60 if no thumbnail, 160 with thumbnail) + text: truncateTemplateText(text, textLimit), actions: actions.slice(0, 4), // LINE limit: max 4 actions thumbnailImageUrl: options?.thumbnailImageUrl, imageAspectRatio: options?.imageAspectRatio ?? "rectangle", @@ -125,9 +173,14 @@ export function createCarouselColumn(params: { imageBackgroundColor?: string; defaultAction?: Action; }): CarouselColumn { + // LINE caps a carousel column's text at 60 chars when the column carries a + // title or thumbnail image, and 120 chars otherwise. Sending an over-length + // text makes LINE reject the whole carousel, so mirror the conditional limit + // the buttons template already applies above. + const textLimit = resolveTemplateTextLimit({ ...params, textOnlyLimit: 120 }); return { title: params.title?.slice(0, 40), - text: params.text.slice(0, 120), // LINE limit + text: truncateTemplateText(params.text, textLimit), actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column thumbnailImageUrl: params.thumbnailImageUrl, imageBackgroundColor: params.imageBackgroundColor, @@ -256,9 +309,7 @@ export function createProductCarousel( return createCarouselColumn({ title: product.title, - text: product.price - ? `${product.description}\n${product.price}`.slice(0, 120) - : product.description, + text: formatProductCarouselText(product.description, product.price), thumbnailImageUrl: product.imageUrl, actions, });