From 0799a6c045a87c3af95023fea75c3ba7281c2623 Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Fri, 7 Aug 2026 19:38:43 +0700 Subject: [PATCH 1/4] feat(chat): render image uploads, loop embeds, swift cards and forwards (t181) --- apps/chat-server/src/contract.ts | 7 +- .../src/providers/mock-provider.ts | 79 ++++- chat/src/components/message-row.tsx | 79 ++++- chat/src/index.css | 14 + chat/src/lib/teams-client.ts | 7 +- core/teams-render.js | 161 ++++++++- core/teams-render.test.ts | 323 ++++++++++++++++++ .../done/181-teams-message-type-coverage.md | 127 +++++++ scripts/t181-corpus-regression.mjs | 109 ++++++ scripts/t181-mutation-check.mjs | 99 ++++++ 10 files changed, 975 insertions(+), 30 deletions(-) create mode 100644 docs/tasks/done/181-teams-message-type-coverage.md create mode 100644 scripts/t181-corpus-regression.mjs create mode 100644 scripts/t181-mutation-check.mjs diff --git a/apps/chat-server/src/contract.ts b/apps/chat-server/src/contract.ts index e364b117..da643157 100644 --- a/apps/chat-server/src/contract.ts +++ b/apps/chat-server/src/contract.ts @@ -61,7 +61,7 @@ export interface ChatConversation { /** A file / call-recording / card chip parsed from a message. Generic lift of `TeamsAttachment`. */ export interface ChatAttachment { - kind: "file" | "recording" | "card" + kind: "file" | "image" | "recording" | "card" name?: string type?: string /** Opens in a new tab (provider SSO). */ @@ -69,6 +69,11 @@ export interface ChatAttachment { /** Already media-proxied when the source is provider-hosted (e.g. Teams AMS). */ thumbnailUrl?: string title?: string + /** Card body text, when the provider payload carried more than a title (t181). */ + text?: string + /** Intrinsic pixel size of a `kind: "image"` preview β€” reserves the box before the bytes load. */ + width?: number + height?: number } /** One reaction bucket on a message. Generic lift of `TeamsReaction`. */ diff --git a/apps/chat-server/src/providers/mock-provider.ts b/apps/chat-server/src/providers/mock-provider.ts index 3a1a694f..2c55bd89 100644 --- a/apps/chat-server/src/providers/mock-provider.ts +++ b/apps/chat-server/src/providers/mock-provider.ts @@ -215,9 +215,9 @@ function richSeed(): Fixture[] { topic: "Design review", title: "Design review", memberIds: ["other-oid", "third-oid"], - lastMessageId: "6008", - lastMessageTs: ago(3), - lastMessagePreview: "and one more thing", + lastMessageId: "6014", + lastMessageTs: ago(1), + lastMessagePreview: "Morning team.. can confirm if this link is valid?", readTs: ago(30), }), messages: [ @@ -294,6 +294,77 @@ function richSeed(): Fixture[] { senderId: OTHER, senderName: "Other Person", }), + // t181 β€” the five message classes that used to render empty or lossy. Bodies are the real + // renderer output for the corresponding probe payloads, so the mock stack shows + // exactly what a live thread now shows. + msg({ + id: "6009", + ts: ago(2), + body: "

the pronunciation like this, any feedbacks guys?

", + senderId: OTHER, + senderName: "Other Person", + attachments: [ + { + kind: "image", + name: "image (6).png", + type: "png", + url: "https://example.sharepoint.com/:i:/g/personal/x/IQAC", + thumbnailUrl: + "/api/chat/media?service=teams&url=https%3A%2F%2Fas-api.asm.skype.com%2Fv1%2Fobjects%2F0-ea-d12-mock%2Fviews%2Fimgo", + width: 96, + height: 96, + }, + ], + }), + msg({ + id: "6010", + ts: ago(2), + body: "

Could i ask if we can fix it in local development env?

", + senderId: THIRD, + senderName: "Third Person", + attachments: [ + { + kind: "card", + title: "Loop component", + url: "https://example.sharepoint.com/:fl:/g/personal/x/IQDcSTYg", + }, + ], + }), + msg({ + id: "6011", + ts: ago(2), + body: '

Everyoneβ€”that\u2019s a wrap. Here\u2019s the rundown. Loop page

', + senderId: THIRD, + senderName: "Third Person", + }), + msg({ + id: "6012", + ts: ago(1), + body: '
', + senderId: OTHER, + senderName: "Other Person", + }), + msg({ + id: "6013", + ts: ago(1), + body: "", + senderId: THIRD, + senderName: "Polly", + attachments: [ + { + kind: "card", + title: "πŸ”“ Non-Anonymous", + text: "Subjectively, how do you feel about your velocity this week versus last", + }, + ], + }), + msg({ + id: "6014", + ts: ago(1), + body: 'Morning team.. can confirm if this link is valid?\r\n
Forwarded
This is the link for Android, copied from eIRIS
', + senderId: OTHER, + senderName: "Other Person", + }), ], }, { @@ -524,6 +595,8 @@ export class MockProvider implements ChatProvider { ] } + // The mock forwards quotes/mentions to sendReply so the mock stack exercises the same wiring the + // Teams path uses (t182) β€” an attachment send must not lose them. async uploadImage(convId: string, _image: UploadImage, text?: string): Promise { const r = await this.sendReply(convId, text || "[image]") return { ok: true, msgId: r.ts } diff --git a/chat/src/components/message-row.tsx b/chat/src/components/message-row.tsx index 7b706139..7e3ff014 100644 --- a/chat/src/components/message-row.tsx +++ b/chat/src/components/message-row.tsx @@ -598,8 +598,14 @@ function ChatMessageRow({ {attachments.length > 0 && (
{attachments.map((a, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: attachments are immutable per message, no reorder - + + setLightboxMedia({ src, kind: "image", convId, msgId: message.id }) + } + /> ))}
)} @@ -1049,10 +1055,45 @@ function ChipCopyButton({ url }: { url: string }) { ) } -/** A file / call-recording / card chip below the message body (t141). A file opens SharePoint in a - * new tab; recordings/cards show a proxied thumbnail preview (no inline playback). */ -function AttachmentChip({ attachment: a }: { attachment: TeamsAttachment }) { - if (a.kind === "file") { +/** A file / image / call-recording / card chip below the message body (t141). A file opens SharePoint + * in a new tab; an uploaded image renders inline and opens the lightbox (t181); recordings/cards + * show a proxied thumbnail preview (no inline playback). */ +function AttachmentChip({ + attachment: a, + onOpenImage, +}: { + attachment: TeamsAttachment + onOpenImage?: (src: string) => void +}) { + // An image pasted into compose arrives as a FILE, not inline media (t181) β€” render the AMS preview + // as a real picture rather than a filename chip. `width`/`height` reserve the box before the bytes + // land so the thread doesn't jump; the SharePoint `url` stays reachable via the copy affordance. + if (a.kind === "image" && a.thumbnailUrl) { + const src = a.thumbnailUrl + // The button, not the img, is the interactive element, so it needs the accessible name. `alt` + // falls back to "" for an unnamed attachment (correct β€” a decorative img), which would leave + // the button announced as just "button". + const label = a.name ? `Open image: ${a.name}` : "Open image" + return ( + + ) + } + + if (a.kind === "file" || a.kind === "image") { const inner = ( <> @@ -1119,8 +1160,8 @@ function AttachmentChip({ attachment: a }: { attachment: TeamsAttachment }) { } // card - return ( - + const cardInner = ( + <> {a.thumbnailUrl ? ( )} - {a.title || "Card"} - + + {a.title || "Card"} + {a.text && {a.text}} + + {a.url && } + + ) + // A Loop/Fluid embed carries a SharePoint componentUrl (t181) β†’ open it like a file chip. A bot + // card has no url and stays inert, as before. + return a.url ? ( + + {cardInner} + + ) : ( + {cardInner} ) } diff --git a/chat/src/index.css b/chat/src/index.css index b272d3e8..857308ee 100644 --- a/chat/src/index.css +++ b/chat/src/index.css @@ -661,6 +661,20 @@ overflow: hidden; text-overflow: ellipsis; } +/* A forwarded block reads identically to a reply quote without a label (t181). The label is a real + DOM node (`.forward-label`, stamped by the renderer) rather than CSS `content`, because generated + content is not reliably exposed to screen readers β€” a sighted-only label would leave the meaning + undiscoverable to AT. `user-select: none` keeps it out of copied text. */ +.teams-message-body blockquote.forward > .forward-label { + display: block; + font-size: 0.72em; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + opacity: 0.6; + margin-bottom: 0.2em; + user-select: none; +} .teams-message-body code, .teams-message-body kbd, .teams-message-body pre { diff --git a/chat/src/lib/teams-client.ts b/chat/src/lib/teams-client.ts index b1e236ee..d5d26a67 100644 --- a/chat/src/lib/teams-client.ts +++ b/chat/src/lib/teams-client.ts @@ -64,12 +64,17 @@ export interface ConversationsPage { /** A file / call-recording / Swift-card chip parsed from a message (t141). `url` opens in a new tab * (SharePoint files ride the browser's SSO); `thumbnailUrl` is already media-proxied when it's AMS. */ export interface TeamsAttachment { - kind: "file" | "recording" | "card" + kind: "file" | "image" | "recording" | "card" name?: string type?: string url?: string thumbnailUrl?: string title?: string + /** Card body text, when the provider payload carried more than a title (t181). */ + text?: string + /** Intrinsic pixel size of a `kind: "image"` preview β€” reserves the box before the bytes load. */ + width?: number + height?: number } /** One reaction bucket on a message (t142): a named Teams emotion key resolved to a display emoji, diff --git a/core/teams-render.js b/core/teams-render.js index 57655abe..6a4b2f30 100644 --- a/core/teams-render.js +++ b/core/teams-render.js @@ -120,10 +120,35 @@ function tagEmoji(html) { }) } -// Does the rendered HTML carry anything visible (text or an image)? An empty/whitespace-only body -// (e.g. `

`) falls back to the attachment chip, matching the pre-t133 behavior. +// A Loop recap link (`itemtype=…/FluidAutoEmbedLink`) ships as an anchor with NO text node, so it +// renders zero-width and invisible β€” 12 of 1963 probed messages (t181). Give an EMPTY one a label so +// it is clickable; an anchor that already carries content is left exactly as authored. +function labelFluidLinks(html) { + return html.replace( + /(]*itemtype\s*=\s*(["'])[^"']*FluidAutoEmbedLink[^"']*\2[^>]*>)(\s*)(<\/a>)/gi, + (_full, open, _q, _ws, close) => `${open}Loop page${close}`, + ) +} + +// A forwarded block is `
` and today renders identically to a reply +// quote, which is misleading (t181). Tag it so the renderer can label and style it; `class` survives +// the DOMPurify allowlist while `itemtype` does not. A blockquote that already has a class is left +// alone. The label itself is a DOM node, not CSS `content` β€” generated content is not reliably +// exposed to screen readers, so the meaning would be sighted-only. +function labelForwards(html) { + return html.replace(/]*)>/gi, (full, attrs) => { + if (!/\bitemtype\s*=\s*(["'])[^"']*Forward[^"']*\1/i.test(attrs)) return full + if (/\bclass\s*=/i.test(attrs)) return full + return `
Forwarded` + }) +} + +// Does the rendered HTML carry anything visible (text or a media element)? An empty/whitespace-only +// body (e.g. `

`) falls back to the attachment chip, matching the pre-t133 behavior. `