diff --git a/CLAUDE.md b/CLAUDE.md index 595b9f6..7008868 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ A lightweight Electron app that connects to a remote Chromium-based browser via - **Unread badges by group**: Sidebar unread counts are computed by `aggregateUnread` (`src/lib/unread-aggregator.ts`) and keyed by `groupKey` (from the notification entry) falling back to `groupKeyForUrl(url)` — Slack's per-workspace `slack:{teamId}`, else URL origin. Every tab/pin of the same app shares one count whether or not it captured the notification, and a dormant pin still badges by resolving its saved URL through the same key derivation. - **Local tabs**: Real local web pages rendered as in-DOM Electron ``s on a shared `persist:local` session (`src/components/local-webviews.tsx`) — full device access (OS notifications, speaker/mic, camera, screen-share) that CDP screencast tabs can't have. Because a `` is an in-page OOPIF, React overlays (dialogs, menus, tooltips, the settings sheet) stack **above the live page via CSS z-index** — no native z-order, no freeze. `activeKind: 'cdp' | 'local'` chooses the surface and routes the toolbar/nav hotkeys (`RemotePage` vs the active webview's methods). The renderer holds `LocalTab` metadata and maps webview DOM events to it; only the active webview is shown (others `display:none`, kept alive in the background). All open local tabs persist + restore on launch; pinned ones (a `pinned` flag, distinct from CDP PINNED pins) sort atop the LOCAL TABS section. Unpacked MV3 extensions load into the local session only (`localExtensionPaths`) and their content scripts inject into webview guests; the toolbar shows a Chrome-like action icon per extension (opens its popup in a popover), and popup/options also open as a local tab via the `chrome-extension://` URL. Permissions auto-granted behind the `autoGrantLocalMedia` setting (a `media` request triggers `askForMediaAccess`); packaging ships mic/cam/audio-capture Info.plist keys + entitlements (`build/entitlements.mac.plist`, hardened runtime). See `docs/adr/0005-local-tabs-base-window.md`. - **Web build (no Electron)**: The same renderer runs as a plain web app via `web/server.mjs` — a Node HTTP proxy that serves the built `dist/` and exposes the whole `window.cdp` surface over **SSE** (`GET /api/events`, server→browser pushes incl. screencast frames) + **POST** (`/api/invoke`, `/api/send`, `/api/cdp-batch`, and REST for tabs/config/ui-state/pins/notifications). An optional **WebSocket** transport (`/api/ws`) supersedes SSE+POST when reachable — the user picks `Auto / Fastest (WS) / Streaming / Basic` in settings (2×2 toggle, web-only, `localStorage`). When WS is ready, frames + events + input all ride the one full-duplex socket. WS needs three lines in the nginx custom config (`proxy_http_version 1.1`, `proxy_set_header Upgrade $http_upgrade`, `proxy_set_header Connection $http_connection`); without them the client silently falls back to SSE+POST. See `docs/adr/0007-web-websocket-transport.md`. The proxy→CDP hop is still WS. The renderer installs a web `window.cdp` (`src/lib/cdp-web-transport.ts`, a thin assembler) when no preload exists, satisfying the same `CdpBridge` contract; the transport is split into named seams — a **Downlink** (`src/lib/downlink-dispatcher.ts`: one live WS-or-SSE source, decoder→filter→fan-out→toast-once dispatcher) and an **Uplink** (`src/lib/uplink-router.ts`: WS/stream/POST adapters + ready-transport router), with E2E sealed/opened once per direction through `src/lib/crypto-context.ts`. Input is coalesced via `src/lib/input-coalesce.ts`; the proxy acks frames itself, **except** for a WS client that announces ack-after-paint support (a plaintext `frame-ack-mode` control) — for that client the proxy **defers** its remote-ack and gates the next Screencast Frame on the client's post-paint `frame-ack`, so at most one frame is in flight on the link and a slow link can't accrue a stale-frame backlog (`core/frame-ack-gate.js`, the pure one-in-flight gate + a watchdog that frees the slot if a paint-ack never lands — its window is adaptive to measured paint latency via `core/paint-ack-pacer.js`, t096; the renderer fires the ack from `viewport.tsx` after it paints, via `window.cdp.ackPaintedFrame`; SSE/non-supporting clients keep the eager self-ack — see `docs/tasks/done/056-*`); theme follows `matchMedia`. **Always-on latency metrics** (`src/lib/latency-metrics.ts`, t057) ride the same seams: the WS uplink fires a plaintext `ping` (monotonic stamp) every 20s — a keepalive against proxy idle-reap plus an RTT/jitter EWMA probe — and the server echoes `{ t: "pong", seq, ts }` (RTT is measured only on the client clock); every Screencast Frame envelope carries a server `serverTs` so the client computes frame age (`now − serverTs + rtt/2`), recorded by the dispatcher before fan-out. Collection runs continuously (no `?perf=1`); the HUD is `src/components/latency-hud.tsx` (t059), always-on in the status bar. RTT/jitter report unavailable on the SSE+POST fallback. A `window.webCaps` flag (read through one accessor — `getCaps()` in `src/lib/caps.ts`, never inline) gates Electron-only surfaces. Local tabs are gated **structurally at the data source**: `useLocalTabs()` (`src/hooks/use-local-tabs.ts`) reads `caps.localTabs` once and returns an empty list + no-op handlers on web, so the renderer can't drive local-tab logic there (`LocalWebviews` never mounts, the new-tab kind toggle is hidden, Cmd+T/Cmd+Shift+T resolve to CDP only). Extensions are still gated at render only. `window.local` is a no-op stub (the safety net, not the mechanism). See `docs/conventions/feature-gates.md`. Pure shared logic lives in `core/` CJS modules — `cdp-endpoints.js` (`/json` URL builders), `settings-store.js` (settings/pins/ui-state), `notifications-sidechain.js` (Notification Side-Channel state machine + store, DI), `remote-page-connector.js` (Remote Page connect choreography, DI), `notifications.js` (dedup/cap/toast gating, Slack workspace key: `parseSlackContext`/`slackGroupKey`), `theme-emulation.js`, `crypto-envelope.js` (AES-256-GCM server side), `line-splitter.js` (NDJSON reassembly), `frame-throttle.js`, `frame-ack-gate.js`, `quality-tier.js`, and `notif-mutes.js` (per-device mute key + per-device unread, web push gate) — consumed by both `main.js` and `web/server.mjs`. Run `pnpm web`. See `docs/adr/0006-web-proxy-sse-transport.md`. The web build is an installable **PWA** (`public/manifest.webmanifest` with `APP_TITLE`-injected name + `public/sw.js`); the manifest is phone-and-iPad friendly (`"orientation": "any"` since t081 — iOS ignores the field, Android honors it; `viewport-fit=cover`; `interactive-widget=resizes-content` in the viewport meta so iOS shrinks the layout viewport when the keyboard opens; full height is driven by `--app-h` (set by `initAppHeight` in `src/lib/app-height.ts` to `visualViewport.height` — `100dvh` is only the pre-JS fallback); on iOS the keyboard also shifts the visual viewport up (`visualViewport.offsetTop`), so `app-height.ts` publishes `--vv-top` and toggles `html.kb-open` so `#root` translates to follow the visual viewport and bottom-anchored composers collapse the home-indicator inset the keyboard covers; `font-size: max(16px,1em)` on inputs prevents iOS auto-zoom on focus; safe-area insets are applied per-component — sidebar scroll content uses `pb-[max(0.5rem,env(safe-area-inset-bottom))]`, status bar uses `pb-[env(safe-area-inset-bottom)]`; sidebar defaults to 180px on viewports ≤1100px; an install nudge banner (`install-banner.tsx`) prompts Safari-tab visits to Add to Home Screen). Has a web-only **push-notification** toggle (`webPush` ui-state) that drives real **Web Push** on installed PWAs (iOS 16.4+) — VAPID-signed payloads from the server (`web-push` library) reach a service-worker `push` handler that fires `showNotification` even when the PWA is backgrounded or the screen is locked; clicks post-message back to the page and route through the same `notificationActivate` listeners as in-app clicks — on the Phone Shell that listener deep-routes into the **Conversation Reader** (t080): warm taps carry the payload entry (store entry wins via `resolvePushEntry`), cold taps (no window) ride a one-shot `?notif=` URL the SW sets on `openWindow`, consumed by `src/lib/push-route.ts` helpers once the store loads (gone entry → Inbox). The push payload also stamps the conversation identity (`channelId`/`slackKind`/`slackTs`/`slackThreadTs`) and an `unread` count, which the SW mirrors to the home-screen icon via `setAppBadge` (the page keeps it live as entries are read). **Per-device delivery (t093):** capture is global but *delivery* is per-device — each push subscription carries a `deviceId`, and `sendPushToAll` reads that device's master + mutes from ui-state (`notificationsEnabled_` + `notifMutes_`, written via the same device-keyed remap seam as `webPush_`; `core/settings-store.js` round-trips device-suffixed keys by prefix so they survive a PWA refresh). For each sub it **skips** the push when that device's master is off or it muted the entry's `muteKey` (`core/notif-mutes.js`: slack→`groupKey`, else `adapter`), and otherwise stamps a **per-device `unread`** (`unreadExcluding`, excluding that device's muted sources) so the badge stays honest per device. Defaults are opt-out (no stored master = on, no stored mutes = nothing muted; a sub with no `deviceId` keeps receiving). The global `notificationsEnabled` stays Electron-only (gates `shouldNotifyOs`), and on Electron a plain global `notifMutes` now gives the same **per-source mute channels** as the PWA (t101): `shouldNotifyOs` and the dock badge honor it, the Settings notification card is one shared component across builds (push row web-only; Slack mute rows sourced from the sweep's capture health on web vs `slackMuteRows(notifications)` — captured hijack entries labelled by their workspace `source` — on Electron), and `app.tsx`'s badge counts apply mutes on both builds. Foreground tabs still get the in-page `Notification` API as before. Subscriptions persist in `web-push-subs.json` next to the settings file. **Push delivery hardening (t095, ADR-0014):** the server is the authoritative source of `deviceId`, reconciled by push endpoint (`core/push-subscriptions.js`:`reconcileDeviceId`) — after a storage wipe the same endpoint recovers its prior `deviceId` and per-device prefs; the SW push handler (`src/lib/push-notification.ts`:`buildNotificationContent`) always calls `showNotification` (real payload or generic fallback) to avoid WebKit **userVisibleOnly** revocation; the server fans out with `urgency:"high"`, `TTL:1800` (`core/push-send-options.js`) for timely, non-stale delivery; on app foreground the client re-validates the subscription once (`src/lib/push-revalidate.ts`:`createPushRevalidateGate`) to recover a revoked sub before the next push arrives. See `CONTEXT.md` for **Web Push Subscription** and **userVisibleOnly revocation** glossary entries. The toggle is disabled in Safari-tab mode (Web Push needs standalone display), and lowers input latency with a **streaming input channel** — one long-lived `POST /api/input-stream` (fetch `ReadableStream` body over HTTP/2, NDJSON frames reassembled by `core/line-splitter.js`) that a probe/`stream-ack` confirms before use and that falls back to `/api/cdp-batch` if a proxy buffers it. Streaming needs `proxy_request_buffering off` upstream to activate; when it can't (the default behind nginx + an SSO proxy), mouse input is **event-driven** so it doesn't flood the fallback: a **hover gate** (`createHoverGate`) holds buttons-up moves and emits one resting position only when the cursor stops (drag moves bypass it and track live; clicks carry their own coords), and the `/api/cdp-batch` fallback is **single-flight with move-collapsing** (`createSingleFlight` — one POST in flight, consecutive `mouseMoved` collapse to the latest) so the rate auto-adapts to link RTT instead of backing up fire-and-forget POSTs and starving clicks. See `docs/tasks/done/013-*`. An optional **E2E mode** (set `E2E_PASSPHRASE` on the server) seals every `/api` body + SSE frame in AES-256-GCM (`core/crypto-envelope.js` server / `src/lib/crypto-envelope.ts` browser; the single owner is `src/lib/crypto-context.ts` — the uplink seals once before leaving, the downlink opens once on arrival) so content stays opaque to a corporate TLS-intercepting proxy; a verifier handshake rejects a wrong passphrase, and with E2E off everything is plaintext as before. It defeats network content inspection, not endpoint screen capture. See `docs/tasks/done/012-*`. -- **Teams chat app (t128–t145, t149–t163, ADR-0019, ADR-0022)**: A standalone Microsoft Teams chat surface — its own Vite app in the flat `chat/` dir (decision 1; no monorepo), built by `vite.config.chat.ts` (`root: chat/`, `base: /chat/`) to `dist-chat/` via `pnpm chat:build`, and served **same-origin at `/chat`** (decision 12) by `web/server.mjs`'s `serveChat` (static assets + SPA `index.html` fallback, path-scoped so it never shadows the `/` browser PWA). It **reuses the shared shadcn design system** (a `@` → `src/` Vite alias pulls in `src/components/ui/*`, `src/index.css` theme, HugeIcons, Manrope/DM-Mono, `cn`) — not forked. Its PWA manifest + service worker are scoped to `/chat/` so it installs as a distinct app. The home screen is a `ConversationList` reading `GET /api/teams/conversations` (t127) with full four-state coverage (loading skeleton / empty / error+Retry / populated rows: label + preview + relative time). A row tap opens the **thread pane** (t129): `chat-app.tsx` runs a **list+pane** layout (two-pane on wide via `useIsWide` ≥768px; stacked list→thread→back on phone), and `thread-view.tsx` + `message-row.tsx` render a conversation's messages (own = right/accent, others = left/muted; sender + relative time; `(edited)` + `message deleted` markers; scroll-to-top lazy-loads an older page via a `before` ts cursor). Messages come from `POST /api/teams/history` (in-page CA-proof fetch → `core/teams-render.js` → `ReaderMessage[]`, upserted to the `messages` table via `teams-store.upsertMessages`). `core/teams-render.js` (pure, TDD; mirrors `slack-render.js`'s contract) reduces Teams `RichText/Html` to **safe plain text** rendered as a React text node — **never `innerHTML`**, so it's XSS-safe with zero sanitizer dep (a rich-HTML render via DOMPurify is a scheduled follow-up per the chat-ui-lib research); it resolves `` mentions to their display text and degrades cards/attachments to a `[card]`/`[attachment]` chip. A bottom **composer** (t130 — auto-grow textarea, Enter=send / Shift+Enter=newline, built on shadcn, no lib) sends via `POST /api/teams/reply` (in-page `messages` POST → 201 `{OriginalArrivalTime=id}`; returns the `clientmessageid` for t131's echo-dedup), optimistically appends the self message, and fails honestly (draft retained via `reduceSend`, mirrors `slack-reply.ts`). The composer is a **Tiptap rich editor** (PSN-92/PSN-94, `composer.tsx` — migrated off the raw contenteditable because execCommand couldn't do live markdown without caret-bleed; ProseMirror's mark model fixes it): StarterKit v3 (bold/italic/underline/strike/code/codeBlock/blockquote/lists/link + **markdown input rules** `**b**`/`` `c` ``/`> `/`- `/`1. `/```` ``` ```` — no caret bleed) + a custom `RosterMention` (emits the same `data-mri`/`data-name` pill `outgoingFromEditor` already maps to Teams per-token wire spans, so the send path is unchanged; suggestion dropdown driven off `fetchRoster`). **An outgoing `properties.mentions` entry must carry ALL of `@type` + `mri` + `mentionType:"person"`, and the `mri` must be the full `8:orgid:{oid}` form** (PSN-120, live-verified against `48:mentions` — the service-side feed Teams fans a real mention into). An entry short any one of those still POSTs 201 and still renders correctly in our own UI, but Teams treats it as inert markup: the recipient sees unstyled plain text where the pill belongs and is never actually mentioned (no activity entry, no notification). So the Teams-native fields are stamped at the one Teams boundary (`teamsReply` in `web/server.mjs`), the service-agnostic `MentionRef` stays `{itemid, id, displayName}`, and `chat-client.sendReply` normalizes the FE's `mri` → the contract's `id` (the mirror of `fetchRoster`'s `id` → `mri`) — that one field-name mismatch silently dropped every `mri` between PSN-93's BFF cutover and PSN-120. `scripts/mention-spike.mjs` is the live re-check: it sends the shape variants, reads the oracle, and cleans up after itself. The toolbar is a width-responsive **Format toggle** (a shadcn **ToggleGroup** of bold/italic/underline/strike that highlights the caret's active marks via `editor.isActive` — re-rendered on every `onTransaction` so an empty-selection toggle lights the chip at once, + inline-code/code-block/quote/insert-link/clear as shadcn **Tooltip** buttons that also carry an `active` + `aria-pressed` state for the block they toggle; the Aa toggle + Attach button `onMouseDown preventDefault` to keep editor focus; inline ≥480px, collapsed behind an Aa toggle below). Enter sends / Shift+Enter runs `splitBlock` (new paragraph) outside lists+code-blocks, HardBreak line-break within a list item / ⌘·Ctrl+Enter always sends / Enter in a list or code block keeps its native behaviour; an @-mention while open owns Enter. Plus an **emoji picker**, and **GIF + sticker** pickers (`gif-picker.tsx` → Giphy via the BFF `/api/chat/giphy` proxy, server-side `GIPHY_API_KEY`). A picked GIF/sticker is a DIRECT send (never through the contenteditable — `cleanEditorHtml` strips ``), shaped by `chat/src/lib/teams-gif.ts` into the native Teams `RichText/Html` **AnimatedImage** wire form (`` — live-proven PSN-94: Teams accepts + normalizes the id to `x_{id}`, so it round-trips as an animated GIF natively). A successful send **write-through marks the conversation read** — `POST /api/teams/mark-read` → in-page `PUT {chatServiceBase}/v1/users/ME/conversations/{convId}/properties?name=consumptionhorizon` body `{consumptionhorizon:"{id};{ts};0"}` (**live-verified 200**), best-effort so it never fails the send. Read state is **bidirectionally synced** with native Teams since PSN-102 (see the read/unread bullet below), so `teams-store`'s `read_state` is now a monotonic **mirror** of Teams' own horizon and the chat BFF owns what the user sees. **DM/group-DM names** (t131, `core/teams-names.js`): a DM/group-DM has no topic, so the list + thread header resolve real names instead of a "Direct message"/"Group chat" fallback. A 1:1 id encodes both members as **bare AAD oids** (`19:{oidA}_{oidB}@unq.gbl.spaces` — live Teams uses bare oids, NOT `8:orgid:` MRIs; self is dropped by a **normalized-oid** compare, or a DM mislabels as yourself), but `otherMrisFromId` **re-prefixes them to real `8:orgid:` MRIs on the way out** — the 1:1 roster publishes its return verbatim as `RosterMember.mri`, and a bare oid there is silently not a mention when it reaches `properties.mentions` (PSN-120); it also keeps the `users` name cache single-keyed per person instead of a bare-oid row from the 1:1 path plus an MRI row from the group path. A group-DM's roster comes from an in-page `GET /v1/threads/{id}` fetch. The unique member oids are resolved in **one in-page Microsoft Graph `POST /v1.0/directoryObjects/getByIds {types:["user"]}` batch** (Teams graph bearer, CA-proof) → `composeTitle` (**Teams-accurate**, t137 — verified live against the real Teams rail: a 1:1 shows the other's **full** display name; a topic-less **group** uses **given (first) names** sorted alpha, joined `A and B` / `A, B, and C` (Oxford) / `A, B, +N` for 4+; the self **Notes** chat is `48:notes` titled `{you} (You)` — `isReservedConversation` now lets `48:notes` through while `48:notifications`/`48:mentions` stay hidden, and `conversationKind` returns a `"self"` kind). Names cache in a `users` table (`mri`→name, resolve-once); the whole enrichment is best-effort — a Graph failure degrades to the fallback label and never fails the list. **Instant switch + scroll-persist** (t132, `chat/src/lib/thread-keepalive.ts`): opening a conversation keeps its thread mounted in its own pane (MRU keep-alive, cap ~8, evict least-recently-viewed — same pattern as the local-tabs webviews); inactive panes are `display:none`, not unmounted, so switching is a visibility toggle (no refetch → instant) and each conversation's scroll position persists (a ref tracks live `scrollTop`, a `useLayoutEffect` restores it on show — `display:none` drops it otherwise). First open mounts+fetches; re-open is instant. **Rich message render** (t133): `core/teams-render.js` emits mention-resolved, entity-intact HTML (not plain text); `chat/src/components/message-row.tsx` renders it via `dangerouslySetInnerHTML` — but ALWAYS through `chat/src/lib/sanitize-message.ts` (one memoized **DOMPurify** instance = the single XSS boundary): allowlist `b/strong/i/em/u/s/a/code/pre/kbd/ul/ol/li/blockquote/br/p/span/img`; `afterSanitizeAttributes` drops non-`http(s)`/`mailto` hrefs + forces `target=_blank rel="noopener noreferrer"`, keeps `.mention` spans + emoji ``. Verified live: bold/link/mention/code/list/quote render while a `", + "file:///etc/passwd", + ]) { + const m = msg({ + content: "

x

", + properties: { + cards: JSON.stringify([ + { + contentType: "application/vnd.microsoft.card.fluidEmbedCard", + content: { componentUrl: url }, + }, + ]), + }, + }) + expect(parseAttachments(m)).toEqual([]) + } + }) +}) + +describe("renderBody — FluidAutoEmbedLink (t181)", () => { + // A Loop recap link is an with NO text node → renders as an invisible zero-width link. + // 12 of 1963 probed messages. Probe id 1786068622115. + const href = "https://fwdgroup-my.sharepoint.com/:fl:/g/personal/x/IQC-7ItY?nav=cz0lMkZw" + + it("labels an empty Fluid auto-embed anchor so it is clickable", () => { + const content = `

Here's the rundown.

` + const out = renderBody(msg({ content })) + expect(out).toContain(`]*>[^<]+<\/a>/) + }) + + it("leaves an auto-embed anchor that already has text untouched", () => { + const content = `

Meeting recap

` + expect(renderBody(msg({ content }))).toContain(">Meeting recap") + }) + + it("does not label an ordinary empty anchor", () => { + const content = '

see

' + expect(renderBody(msg({ content }))).toContain('') + }) +}) + +describe("renderBody — video-only body (t181)", () => { + // hasVisibleText only looked for , so a video-only message fell through to the (empty) + // attachment chip and rendered a blank bubble. 3 of 1963 probed. Probe id 1785919202430. + it("keeps a video-only body instead of rendering an empty bubble", () => { + const content = + '
' + const out = renderBody(msg({ content })) + expect(out).toContain(" { + expect(renderBody(msg({ content: "

 

" }))).toBe("") + }) +}) + +describe("renderBody — forwarded blocks (t181)", () => { + // A Forward blockquote is visually identical to a Reply quote today. 5 of 1963 probed. + // Probe id 1779844125066. + it("marks a forwarded blockquote so the renderer can label it", () => { + const content = + 'Morning team..\r\n
copied from eIRIS
' + expect(renderBody(msg({ content }))).toContain( + '
', + ) + }) + + // The label is a real DOM node, not CSS `content`: generated content is not reliably exposed to + // screen readers, so a `::before` label would be sighted-only. + it("stamps the label as a DOM node so screen readers reach it", () => { + const content = + '
copied from eIRIS
' + const out = renderBody(msg({ content })) + expect(out).toContain('Forwarded') + // …and it precedes the forwarded content, so it reads as a heading for it. + expect(out.indexOf("forward-label")).toBeLessThan(out.indexOf("copied from eIRIS")) + }) + + it("labels every forwarded block when a message carries more than one", () => { + const content = + '
one
' + + '
two
' + const out = renderBody(msg({ content })) + expect(out.match(/forward-label/g)).toHaveLength(2) + }) + + it("leaves a reply blockquote unmarked", () => { + const content = + '

hi

ok' + const out = renderBody(msg({ content })) + expect(out).not.toContain('class="forward"') + expect(out).not.toContain("forward-label") + }) +}) + describe("renderBody — URIObject blocks (t141)", () => { it("renders empty for a call-recording-only body (no garbled inner text)", () => { const content = diff --git a/core/teams-send-props.js b/core/teams-send-props.js new file mode 100644 index 0000000..9030d97 --- /dev/null +++ b/core/teams-send-props.js @@ -0,0 +1,68 @@ +// Shared wire-properties builder for every Teams SEND path (t182). Text replies, single-image +// uploads, multi-image uploads and file uploads all post to the same messages endpoint and all +// need the same `properties` payload for quotes and @mentions — but before t182 only the text +// reply path built it, so attaching a file silently dropped both. This module is the one pure +// builder they share (the effectful send lives in web/server.mjs). Tested by teams-send-props.test.ts. + +const { escapeHtml } = require("./teams-ams.js") + +// Build the `properties` object for a send. `quotes` are ReplyRef-shaped ({messageId, sender, +// time}); `mentions` are Teams-native ({itemid, mri, displayName}). `extra` is merged last and +// carries per-path payloads the caller already owns (the file chip's `files` JSON string). +// +// A quoted reply carries `qtdMsgs` (+ formatVariant/hasValidMsgReferences) so Teams renders it as +// a native reply, not just inline blockquote markup (PSN-92, verified against a real reply's wire). +// +// @mentions ride as a JSON-STRING `properties.mentions` (per-token, live-verified) — Teams' own +// wire keeps this as a string, not a nested array. +// +// `@type` + `mentionType` are LOAD-BEARING, not decoration: native Teams stamps both on every +// entry, and an entry missing either is stored fine (201) yet mentions nobody — Teams renders the +// raw per-token spans and never fans the message into the recipient's mention feed. Proven live +// (PSN-120, scripts/mention-spike.mjs) against `48:mentions`, the service-side oracle. Same for a +// bare-oid `mri`: it must be the full `8:orgid:{oid}` MRI. An entry without an mri is DROPPED +// rather than sent — it would notify nobody while looking like it worked. +function buildSendProperties({ quotes = [], mentions = [], extra = {} } = {}) { + const properties = {} + + if (quotes.length) { + properties.qtdMsgs = quotes.map((q) => ({ + messageId: q.messageId, + sender: q.sender, + time: q.time, + message: null, + validationResult: "Valid", + sharedRefId: null, + replyChainId: null, + })) + properties.formatVariant = "TEAMS" + properties.hasValidMsgReferences = true + } + + const usable = mentions.filter((m) => m?.mri) + if (usable.length) { + properties.mentions = JSON.stringify( + usable.map((m) => ({ + "@type": "http://schema.skype.com/Mention", + itemid: m.itemid, + mri: m.mri, + mentionType: "person", + displayName: m.displayName, + })), + ) + } + + return { ...properties, ...extra } +} + +// The caption HTML that rides above an uploaded image/file. `html` is the composer's pre-built +// rich body and is used VERBATIM when present — that is the only way mention spans survive to the +// wire, since escaping the plain text would neuter them. Falls back to escaped text (newlines → +//
), and to "" when there is nothing to say. +function captionHtml({ text, html } = {}) { + if (html && String(html).trim()) return html + if (!text || !String(text).trim()) return "" + return escapeHtml(text).replace(/\n/g, "
") +} + +module.exports = { buildSendProperties, captionHtml } diff --git a/core/teams-send-props.test.ts b/core/teams-send-props.test.ts new file mode 100644 index 0000000..7a7a4f5 --- /dev/null +++ b/core/teams-send-props.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest" + +// @ts-expect-error — CJS module, no types +import { buildSendProperties, captionHtml } from "./teams-send-props.js" + +describe("buildSendProperties — quotes", () => { + it("returns an empty object when nothing is passed", () => { + expect(buildSendProperties()).toEqual({}) + expect(buildSendProperties({})).toEqual({}) + expect(buildSendProperties({ quotes: [], mentions: [] })).toEqual({}) + }) + + it("emits qtdMsgs + formatVariant + hasValidMsgReferences for a quoted reply", () => { + const p = buildSendProperties({ + quotes: [{ messageId: 123, sender: "8:orgid:abc", time: 123 }], + }) + expect(p.formatVariant).toBe("TEAMS") + expect(p.hasValidMsgReferences).toBe(true) + expect(p.qtdMsgs).toEqual([ + { + messageId: 123, + sender: "8:orgid:abc", + time: 123, + message: null, + validationResult: "Valid", + sharedRefId: null, + replyChainId: null, + }, + ]) + }) + + it("carries every quote in order", () => { + const p = buildSendProperties({ + quotes: [ + { messageId: 1, sender: "a", time: 1 }, + { messageId: 2, sender: "b", time: 2 }, + ], + }) + expect(p.qtdMsgs.map((q: { messageId: number }) => q.messageId)).toEqual([1, 2]) + }) +}) + +describe("buildSendProperties — mentions", () => { + // `@type` + `mentionType` are load-bearing: an entry missing either is accepted (201) but + // mentions nobody (PSN-120, verified against the `48:mentions` service oracle). + it("serializes mentions as a JSON STRING with all five load-bearing fields", () => { + const p = buildSendProperties({ + mentions: [{ itemid: 0, mri: "8:orgid:abc", displayName: "Dustin" }], + }) + expect(typeof p.mentions).toBe("string") + expect(JSON.parse(p.mentions)).toEqual([ + { + "@type": "http://schema.skype.com/Mention", + itemid: 0, + mri: "8:orgid:abc", + mentionType: "person", + displayName: "Dustin", + }, + ]) + }) + + it("drops an entry missing an mri rather than sending a mention that notifies nobody", () => { + const p = buildSendProperties({ + mentions: [ + { itemid: 0, mri: "", displayName: "Ghost" }, + { itemid: 1, mri: "8:orgid:real", displayName: "Real" }, + ], + }) + const parsed = JSON.parse(p.mentions) + expect(parsed).toHaveLength(1) + expect(parsed[0].mri).toBe("8:orgid:real") + }) + + it("omits the mentions key entirely when every entry is unusable", () => { + const p = buildSendProperties({ mentions: [{ itemid: 0, mri: "", displayName: "Ghost" }] }) + expect(p.mentions).toBeUndefined() + }) + + it("merges quotes and mentions into one properties object", () => { + const p = buildSendProperties({ + quotes: [{ messageId: 9, sender: "s", time: 9 }], + mentions: [{ itemid: 0, mri: "8:orgid:abc", displayName: "D" }], + }) + expect(p.qtdMsgs).toHaveLength(1) + expect(typeof p.mentions).toBe("string") + }) + + it("passes extra properties through untouched (the file chip payload)", () => { + const p = buildSendProperties({ + mentions: [{ itemid: 0, mri: "8:orgid:abc", displayName: "D" }], + extra: { files: '[{"id":"x"}]' }, + }) + expect(p.files).toBe('[{"id":"x"}]') + expect(typeof p.mentions).toBe("string") + }) +}) + +describe("captionHtml", () => { + it("escapes a plain-text caption and converts newlines", () => { + expect(captionHtml({ text: "a & c\nd" })).toBe("a <b> & c
d") + }) + + it("returns an empty string for blank or missing text", () => { + expect(captionHtml({})).toBe("") + expect(captionHtml({ text: " " })).toBe("") + }) + + // The whole point of t182: an attachment caption must be able to carry mention spans, which + // only survive if the caller's pre-built HTML is used verbatim instead of being escaped. + it("prefers caller-supplied html over text so mention spans survive", () => { + const html = 'Dustin hi' + expect(captionHtml({ text: "@Dustin hi", html })).toBe(html) + }) + + it("falls back to escaped text when html is blank", () => { + expect(captionHtml({ text: "hey", html: " " })).toBe("hey") + expect(captionHtml({ text: "hey", html: null })).toBe("hey") + }) +}) diff --git a/docs/memories/ideas.md b/docs/memories/ideas.md index 34d580a..96d32f0 100644 --- a/docs/memories/ideas.md +++ b/docs/memories/ideas.md @@ -5,3 +5,10 @@ Feature ideas, nice-to-haves, and future directions that don't belong in active **Format:** one line per idea, prefixed with `YYYY-MM-DD`. Newest at the top. Promote to a task file when the idea is ready to spec. --- + +- `2026-08-07` **Inline preview for a linked message.** t183 makes a Teams message link jump in-app; rendering the linked message's *content* inline as a preview card is the richer treatment. Deferred: it needs a cross-conversation fetch plus a new card surface, and it inherits the stored-body staleness trap below (a preview built from the store may show a stale render). The jump delivers most of the value at a fraction of the cost. +- `2026-08-07` **Mentions in edits.** t182 threads mentions through the attachment send paths. The EDIT path still takes plain text and has no mention affordance at all, so editing a message that had mentions cannot preserve or add them. Small and self-contained if it ever bites. +- `2026-08-07` **Teams link unfurls.** 64 of 1963 probed messages carry `properties.links[].preview.{title,description}` that we never render, while Teams web shows a card. Deferred out of t181: sampled titles were junk ("Log in with Atlassian account") and `previewurl` was empty on every sample, so rendering them adds noise, not signal. Revisit gated on a non-empty `previewurl`. +- `2026-08-07` **Teams call transcripts.** 13 of 1963 probed messages are `RichText/Media_CallTranscript`, currently dropped as control noise while recordings chip correctly. Teams web offers an openable "Transcript". Cheap to add if anyone asks. +- `2026-08-07` **`` citation styling.** 18 probed messages carry `[1]`. `cite` is outside the DOMPurify allowlist, so the tag is stripped — `KEEP_CONTENT` preserves the `[1]` text, so meaning survives unstyled. Purely cosmetic. +- `2026-08-07` **Backfill stored message bodies after a renderer fix.** `store.ts` persists the RENDERED `ChatMessage` in the `raw` column, so a renderer improvement only reaches messages that get re-fetched (the newest ~30 of a thread). Older DB-served pages keep the old body forever. A re-render pass over the store would fix that; deliberately out of t181's scope. diff --git a/docs/tasks/done/181-teams-message-type-coverage.md b/docs/tasks/done/181-teams-message-type-coverage.md new file mode 100644 index 0000000..2e29b56 --- /dev/null +++ b/docs/tasks/done/181-teams-message-type-coverage.md @@ -0,0 +1,127 @@ +# 181 — cover the Teams message types that render empty or lossy + +- **Status:** done +- **Mode:** AFK +- **Estimate:** 1d +- **Depends on:** t141 (attachment chips), t151 (card fallback), t162 (recording chips) +- **Blocks:** none + +## Goal + +Five classes of Teams message currently lose content between the provider payload and the rendered bubble: an uploaded image degrades to a filename chip, a Loop/Fluid embed disappears entirely, a video-only message renders as a blank bubble, a bot's adaptive card collapses to the word "Card", and a forwarded block is indistinguishable from a reply. After this task each of them renders what Teams web renders. The governing rule: if Teams web can display it, CDP Chats should too. + +## Why now + +Three of these were reported directly (an image that arrived as a chip, a Loop embed that arrived as nothing, plus the blank bubbles found while probing). A live probe of 1963 raw messages across 20 conversations quantified the rest — 206 messages, the single largest class in the corpus, currently render a chip that says nothing. + +## Evidence + +Probe method: read-only CDP fetch mirroring `web/server.mjs` `fetchTeamsHistoryInPage`, then each raw payload run through `core/teams-render.js` and diffed raw-vs-rendered. + +| # | Gap | Messages | Root cause | +|---|---|---|---| +| A | image sent as a file upload → chip only | 11 | `parseFiles` drops `filePreview.previewUrl` (a valid AMS url) and flattens every file to `kind:"file"` | +| B | Loop/Fluid embed renders nothing | 1 + 12 | `FluidEmbedCard` payload lives in `properties.cards`, but `renderBody` returns early on visible text so the card path never runs; `FluidAutoEmbedLink` is an `` with no text node | +| C | video-only message → blank bubble | 3 | `hasVisibleText` tests `/`; `parseUriObjects` reads only ``, and `cardFallback` reads `properties.cards`, which is empty for these | +| E | forwarded block unlabelled | 5 | `<blockquote itemtype=".../Forward">` gets no marker, unlike a Reply quote | + +Explicitly verified as NOT broken, do not touch: `CustomEmoji` (already `class="emoji"`), code blocks, PDF/json/html chips, reactions, mentions, reply quotes, `Event/Call`, `ThreadActivity/*`, and the 31 deliberately-dropped in-progress recording chunks (t162). + +## Acceptance criteria + +- [x] An image upload with a valid AMS `filePreview.previewUrl` renders inline through the media proxy, with its box reserved, and opens in the lightbox. +- [x] An image upload with a missing or non-AMS preview still renders as a file chip — never a broken `<img>`. +- [x] A `fluidEmbedCard` message renders a card chip linking to its `componentUrl`, alongside any body text. +- [x] An empty `FluidAutoEmbedLink` anchor renders a visible, clickable label. +- [x] A video-only message renders its `<video>`, not an empty bubble. +- [x] A SWIFT card renders the card's real title and text, and a malformed payload falls back to today's generic chip without throwing. +- [x] A forwarded blockquote is visually marked as forwarded; a reply quote is unchanged. +- [x] The gap census over the same 1963-message corpus reports 0 for every counter above. +- [x] `core/teams-render.js` stays pure — no I/O, no DOM. +- [x] No new dependency. No adaptivecards package (grilled #7). + +## Test plan + +### Layer 1 — pure logic (TDD), `core/teams-render.test.ts` + +Fixtures are taken verbatim from the captured corpus, not invented HTML. + +- [x] `parseAttachments` — png upload with AMS `filePreview` → `kind:"image"`, proxied `thumbnailUrl`, `width`/`height` set +- [x] `parseAttachments` — png upload with a non-AMS preview url → `kind:"file"` (SSRF gate holds) +- [x] `parseAttachments` — png upload with no `filePreview` → `kind:"file"` +- [x] `parseAttachments` — pdf → `kind:"file"`, unchanged +- [x] `renderBody` — video-only body keeps its `<video>` element +- [x] `renderBody` — genuinely empty body still falls back to the chip +- [x] `parseAttachments` — SWIFT with `Swift b64` → card title + text from the decoded AdaptiveCard +- [x] `parseAttachments` — SWIFT with malformed base64 → generic chip, no throw +- [x] `parseAttachments` — CallRecording path unchanged (regression guard for t162 chunk-dropping) +- [x] `parseAttachments` — `fluidEmbedCard` → `kind:"card"` with `componentUrl` +- [x] `renderBody` — empty `FluidAutoEmbedLink` anchor gets a label; a non-empty anchor is untouched +- [x] `renderBody` — Forward blockquote gets `class="forward"`; Reply blockquote unchanged + +### Mutation check + +Revert each production line individually and confirm the matching test goes red. A green suite not wired to the behaviour it names is worse than no suite. + +### Layer 2 — corpus regression + +Re-run the gap census over the captured 1963-message corpus; every counter in the evidence table must reach 0. + +### Layer 3 — visual + +`pnpm chat:mock` with the captured payloads seeded. Screenshot each of the five classes. Filter DOM queries by `getBoundingClientRect().width > 0` — `thread-view` keeps inactive panes mounted under `display:none`. Do **not** verify on a preview deploy: previews share the real `/data` volume and drive the live Teams session. + +## Design notes + +`ChatAttachment.kind` gains `"image"`, plus optional `width`/`height`. A distinct kind rather than `"file"` + a truthy `thumbnailUrl`, because the FE behaviour differs in kind: an inline media surface and a lightbox target, not a chip. + +`core/teams-render.js`: + +| function | change | +|---|---| +| `parseFiles` | image `fileType` + AMS-valid `filePreview.previewUrl` → `kind:"image"` with proxied `thumbnailUrl` and dimensions; everything else unchanged | +| `hasVisibleText` | `/<img\b/i` → `/<(?:img\|video)\b/i` | +| `parseUriObjects` | decode `<Swift b64>` and feed the existing `collectCardText`/`stripCardMarkup` path | +| `parseFluidCards` (new) | `properties.cards` entries with a `fluidEmbedCard` contentType → `kind:"card"` + `componentUrl` | +| `labelFluidLinks` (new) | give an empty `FluidAutoEmbedLink` anchor a text child | +| `labelForwards` (new) | add `class="forward"` to a Forward blockquote | + +`chat/src/lib/sanitize-message.ts` needs **no** allowlist change: `img`, `video`, `class`, `width`, `height` are already permitted. + +SWIFT payloads are untrusted third-party base64→JSON. Parse defensively, reuse `CARD_TEXT_CAP` and the existing escaping, render no card actions. + +## Known trap + +`store.ts` persists the **rendered** `ChatMessage` in the `raw` column and `toChatMessage` (`routes.ts:857`) returns it verbatim, so a renderer fix does not retroactively repair stored rows. `teamsHistory` re-upserts on fetch, so the newest ~30 messages of a thread self-heal when opened; older DB-served pages keep the old body. Verification must use freshly fetched threads. + +## Out of scope + +- Backfilling already-stored message bodies (separate task if wanted). +- Link unfurls from `properties.links` (64 msgs) — sampled previews are junk and `previewurl` was empty on every sample. Deferred to `docs/memories/ideas.md`. +- Call transcripts (13 msgs) — still skipped as control noise. +- `<cite>` citation styling (18 msgs) — stripped by the allowlist but `KEEP_CONTENT` preserves the `[1]` text, so meaning survives. + +## Verification record + +| Gate | Result | +|---|---| +| `core/teams-render.test.ts` | 121 passed (12 new t181 suites) | +| Mutation check (`scripts/t181-mutation-check.mjs`) | 9/9 mutants killed | +| Corpus regression (`scripts/t181-corpus-regression.mjs`) | 1963 raw → 1870 rendered, all gap classes 0 | +| `pnpm typecheck` | clean | +| `pnpm test` | 2490 passed (183 files), no regression | +| `pnpm build` + `node --check web/server.mjs` | clean | +| Biome on changed files | 0 errors | +| Visual (`pnpm chat:mock`) | all five classes verified in the live DOM — inline image loads (96×96 natural size) and opens the lightbox, Loop chip + "Loop page" link carry hrefs, `<video>` present, decoded card shows its question text, `FORWARDED` label renders | + +The mutation check earned its keep: it found that `componentUrl` reached the chip's `href` with no scheme guard, so a `javascript:` payload from a hostile card would have ridden through. Test added, gate added, mutant killed. + +## Definition of Done + +- [x] All acceptance criteria checked +- [x] Layer 1 tests written first, seen red, then green +- [x] Mutation check passed +- [x] `pnpm typecheck && pnpm test && pnpm build` clean +- [x] Biome clean on the changed files +- [x] This file moved to `docs/tasks/done/` in the shipping commit diff --git a/docs/tasks/done/182-attachment-mentions-and-quotes.md b/docs/tasks/done/182-attachment-mentions-and-quotes.md new file mode 100644 index 0000000..cd72632 --- /dev/null +++ b/docs/tasks/done/182-attachment-mentions-and-quotes.md @@ -0,0 +1,95 @@ +# 182 — carry mentions and quotes through attachment sends + +- **Status:** done +- **Mode:** AFK +- **Estimate:** 0.5d +- **Depends on:** none +- **Blocks:** none + +## Goal + +@mentioning someone (or quoting a message) while attaching a file currently sends the attachment and silently drops the mention and the quote. Nobody is notified, no reply chain is formed, and the sender gets no indication anything was lost. After this task, an attachment send carries exactly the same mention and quote payload a text-only send does. + +## Why now + +Silent data loss on a routine action. The send *looks* successful — the message arrives, the file is there — so the failure is invisible until someone asks why they were never pinged. This is the same class of bug as PSN-121 (one image arrived, the rest vanished into a `console.warn`). + +## Acceptance criteria + +- [x] A send with one image + an @mention delivers a real mention (fans into the recipient's activity feed, not just per-token spans in the body) +- [x] A send with multiple images + an @mention delivers one mention, on the first message of the chain only +- [x] A send with a non-image file + an @mention delivers a real mention +- [x] A quoted reply that also carries an attachment forms a native Teams reply (`qtdMsgs`), not a bare blockquote +- [x] A mention entry lacking an `mri` is dropped rather than sent (it would notify nobody while appearing to work) +- [x] Caption HTML reaches the wire verbatim so per-token mention spans survive; a plain-text caption is still escaped +- [x] Sending an attachment with neither mention nor quote produces byte-identical wire content to before this task + +## Test plan + +### Layer 1 — Pure logic (TDD) + +- [x] `buildSendProperties` — quotes produce `qtdMsgs` + `formatVariant` + `hasValidMsgReferences` +- [x] `buildSendProperties` — mentions serialize as a JSON **string** carrying all five load-bearing fields +- [x] `buildSendProperties` — an entry with no `mri` is dropped; an all-unusable list omits the key entirely +- [x] `buildSendProperties` — quotes and mentions merge; `extra` (the file chip payload) passes through +- [x] `buildSendProperties` — empty input yields `{}` (an attachment send with nothing extra is unchanged) +- [x] `captionHtml` — pre-built HTML wins verbatim; blank HTML falls back to escaped text +- [x] `captionPrefix` (teams-ams) — same split for the AMS image body builders + +### Layer 2 — Manual smoke (CDP/IPC) + +Self-chat only (issue constraint — no mutations on other users' threads): + +- [x] Attachment + mention arrives with the mention registered +- [x] Attachment + quote arrives as a native reply +- [x] Plain attachment, no caption — unchanged + +### Layer 3 — Visual review + +- [x] Mention pill renders in the caption above the attachment, not raw markup + +## Design notes + +The wire payload for quotes and mentions was built inline inside the text-reply sender, so the three upload senders had no way to produce it. Extracting it makes the capability shared rather than duplicated. + +- **Contracts changed:** + - `ChatProvider.uploadImage/uploadImages/uploadFile` — third parameter `text?: string` → `opts?: UploadOpts` + - New `UploadOpts` — `{ text?, html?, quotes?, mentions? }`, mirroring the reply path's `opts` + - `buildAmsImageContent` — accepts `captionHtml` alongside `caption` + - `buildAmsImageContentMulti` — third parameter `captionHtml` +- **New modules:** `core/teams-send-props.js` — the one pure builder every Teams send path shares. Justified by four call sites that were previously one-and-three-copies-of-nothing. +- **New ADR needed?** no — this restores intended behavior rather than deciding anything new. + +Three separate layers each independently dropped the payload, which is why the bug survived: the FE zeroed mentions before the call, the client/provider signatures had nowhere to put them, and the backend senders passed an empty `properties`. Fixing any one layer alone would have changed nothing observable. + +```ts +// the shared shape, threaded end to end +interface UploadOpts { + text?: string + html?: string | null // sent VERBATIM — the only way mention spans survive + quotes?: ReplyRef[] + mentions?: MentionRef[] +} +``` + +## Out of scope + +- Backfilling already-sent messages that lost their mentions — unrecoverable, the wire payload was never stored +- Mentions in edits (the edit path takes plain text and has no mention affordance) +- Channel mentions / tag mentions — this surface lists chats only + +## Definition of Done + +- [x] Layer 1 tests written and green +- [x] Layer 2 smoke checklist completed +- [x] `pnpm check` clean +- [x] `pnpm typecheck` clean +- [x] `pnpm test` green +- [x] No commented-out code, no `console.log` debris, no AI attribution +- [x] Task closed: status → done, file moved to `docs/tasks/done/`, t182 in commit + +## Notes + +The mention wire shape is unforgiving and was learned the hard way (PSN-120): an entry missing `@type` or `mentionType`, or carrying a bare oid instead of the full `8:orgid:{oid}` MRI, is accepted with a 201 and mentions nobody. `buildSendProperties` is now the single place that shape is written, so there is one thing to get right instead of four. + +Dropping an mri-less entry rather than forwarding it is a deliberate choice: a mention that silently notifies nobody is worse than no mention, because the sender believes it worked. diff --git a/docs/tasks/done/183-resolve-message-links-in-app.md b/docs/tasks/done/183-resolve-message-links-in-app.md new file mode 100644 index 0000000..360252b --- /dev/null +++ b/docs/tasks/done/183-resolve-message-links-in-app.md @@ -0,0 +1,95 @@ +# 183 — resolve message links inside the app + +- **Status:** done +- **Mode:** AFK +- **Estimate:** 0.5d +- **Depends on:** none +- **Blocks:** none + +## Goal + +A Teams message link pasted into a conversation renders as a plain external anchor: clicking it leaves the app for the Teams web client. After this task, a message link that points at a conversation this app can reach opens *in place* — the thread switches if needed, scrolls to the message, and highlights it — using the jump machinery already built for citations and reply quotes. + +## Why now + +Message links are how people hand each other context. Following one currently means losing your place: a new tab, a second client, and a manual walk back. The app already knows how to land on an arbitrary message (t175 jump mode) — the only missing piece was reading the link. + +## Acceptance criteria + +- [x] Clicking a `teams.microsoft.com/l/message/{convId}/{msgId}` link opens the target in-app +- [x] Clicking a link to a message in the CURRENT conversation jumps without a pane switch +- [x] Clicking a link to a message in ANOTHER conversation opens that conversation, then jumps +- [x] A link to a message not currently loaded still lands (jump mode fetches a window around it) +- [x] This app's own `/chat/c/{convId}?msg={msgId}` links resolve the same way +- [x] Cmd/Ctrl/Shift/Alt-click keeps its browser meaning (new tab / window) +- [x] A non-message link is untouched and still opens externally +- [x] A `javascript:` or `data:` URL never reaches the click handler +- [x] A `/chat/c/` link from a DIFFERENT origin is not resolved locally + +## Test plan + +### Layer 1 — Pure logic (TDD) + +- [x] `parseMessageUrl` — round-trips a URL produced by `buildTeamsMessageUrl` +- [x] `parseMessageUrl` — parses a Teams link with and without the `context` param +- [x] `parseMessageUrl` — decodes a percent-encoded conversation id +- [x] `parseMessageUrl` — accepts the regional Teams hosts +- [x] `parseMessageUrl` — parses this app's own deep link; returns null when `?msg=` is absent +- [x] `parseMessageUrl` — rejects a foreign-origin `/chat/c/` link +- [x] `parseMessageUrl` — rejects a lookalike host (`evilteams.microsoft.com.attacker.test`) +- [x] `parseMessageUrl` — rejects non-http(s) schemes +- [x] `parseMessageUrl` — rejects a non-numeric message id, channel links, and junk + +### Layer 2 — Manual smoke (CDP/IPC) + +- [x] Paste a self-chat message link into self-chat, click it — lands and highlights +- [x] Click a link pointing at a different conversation — pane switches, then lands + +### Layer 3 — Visual review + +- [x] The landing flash reads the same as a citation-chip jump (same affordance, same feedback) + +## Design notes + +The link *builder* already existed for the "Copy link" menu items; this adds the inverse and wires it to the existing jump path. Almost no new behavior is introduced — the click is simply routed to a handler that was already there for citation chips. + +- **Contracts changed:** + - `MessageRowProps` — new optional `onOpenMessageLink(convId, msgId)`; absent → links stay external + - `ThreadViewProps` — new optional `onOpenMessageLink(convId, msgId)`, for cross-conversation targets only +- **New modules:** none — `parseMessageUrl` joins the existing builders in `chat/src/lib/message-url.ts` +- **New ADR needed?** no + +Routing is split by who owns the answer: the thread pane resolves a same-conversation target itself (it already has the jump), and only hands *up* a target it cannot serve. The app-level handler is the same `openCitation` that citation chips use, so cross-conversation jumps inherit behavior that is already proven. + +Host matching is exact-or-subdomain rather than suffix. A suffix check accepts `evilteams.microsoft.com.attacker.test`, which is the standard way this kind of allowlist is bypassed. + +```ts +interface MessageUrlTarget { + convId: string + msgId: string +} +// null = "not a message link we can resolve" → leave it external +parseMessageUrl(raw: string, origin: string): MessageUrlTarget | null +``` + +## Out of scope + +- **Rendering the linked message's content inline** as a preview card. Deliberately deferred: it needs a cross-conversation fetch and a new card surface, and it interacts with the stored-body staleness trap. The jump delivers most of the value at a fraction of the cost. +- Channel message links (`/l/channel/…`) — this surface lists chats only, and the channel link shape carries fields we cannot populate +- Unfurling message links into rich previews in the conversation list + +## Definition of Done + +- [x] Layer 1 tests written and green +- [x] Layer 2 smoke checklist completed +- [x] `pnpm check` clean +- [x] `pnpm typecheck` clean +- [x] `pnpm test` green +- [x] No commented-out code, no `console.log` debris, no AI attribution +- [x] Task closed: status → done, file moved to `docs/tasks/done/`, t183 in commit + +## Notes + +Returning `null` for anything unrecognized (rather than throwing or best-guessing) keeps the failure mode boring: an unparseable link is just a link, exactly as before. + +The refusal to resolve a foreign-origin `/chat/c/` link matters more than it first appears — conversation ids are not origin-scoped, so a link from another deployment would otherwise jump to a completely unrelated local conversation that happens to share the id. diff --git a/docs/tasks/done/184-jump-landing-media-settle.md b/docs/tasks/done/184-jump-landing-media-settle.md new file mode 100644 index 0000000..7658785 --- /dev/null +++ b/docs/tasks/done/184-jump-landing-media-settle.md @@ -0,0 +1,76 @@ +# 184 — hold the jump landing while media settles + +- **Status:** done +- **Mode:** AFK +- **Estimate:** 0.5d +- **Depends on:** none +- **Blocks:** none + +## Goal + +Jumping to a message — from a citation chip, a reply quote, a message link, or a `?msg=` deep link — often lands off-target: the message scrolls past, or ends up above the viewport. After this task the landing is held: the target is re-seated as nearby media finishes decoding, so the jump ends where it aimed. + +## Why now + +Every jump affordance in the app funnels through one function, so a single defect degrades citations, reply quotes, deep links and (as of t183) message links at once. The failure is worst exactly when the jump matters most — a media-heavy thread you can't easily scroll back through by hand. + +## Acceptance criteria + +- [x] Jumping to a message in a thread containing unsized images lands on that message +- [x] Jumping to a message that was NOT loaded in the session (jump-mode window fetch) lands correctly +- [x] The correction is invisible as motion — no second animated scroll after the first +- [x] A second jump fired during the first one's settle window wins outright +- [x] Leaving the thread mid-settle tears the listener down (no leak, no stray scroll on a dead pane) +- [x] A jump in a thread with no media behaves exactly as before + +## Test plan + +### Layer 1 — Pure logic (TDD) + +n/a — this is DOM scroll behavior; the mechanism (a capture-phase `load` listener plus a settle timeout) has no pure core to extract. Covered by Layer 2/3. + +### Layer 2 — Manual smoke (CDP/IPC) + +- [x] Jump into a thread of unsized images — target centered after load +- [x] Jump to a message far outside the loaded page — window fetch, then centered +- [x] Fire a second jump mid-settle — the second target wins +- [x] Switch conversations mid-settle — no stray scroll afterwards + +### Layer 3 — Visual review + +- [x] Landing looks like the layout holding still, not a bounce + +## Design notes + +The jump scrolled the moment the target row existed in the DOM, which is earlier than the moment the layout above it is final. An image or video with no reserved box occupies zero height until it decodes and then grows, pushing everything below it — including the target — down by an unpredictable amount. + +- **Contracts changed:** none — the fix is internal to the thread pane +- **New modules:** none +- **New ADR needed?** no + +The same defect was found and fixed for the unread separator long ago, and the fix carried a comment naming the cause exactly ("Images without reserved boxes grow as they load and shove the separator off-target"). The message jump simply never inherited it. This task brings the two into line and adds what the original lacked: a cancel token, so overlapping jumps and unmount tear down cleanly instead of leaving listeners fighting over the scroll position. + +Re-seat uses instant scrolling rather than smooth: a smooth correction reads as a second animation and looks like a bug, while an instant one reads as the page holding still. The target row is re-queried on each correction because a re-render or a jump-window swap can replace the node. + +t181 reduces how often this triggers — uploaded images now carry `width`/`height`, so their boxes are reserved — but it cannot eliminate it: images from other clients, and any media whose dimensions are unknown, still grow on load. + +## Out of scope + +- Reserving boxes for ALL inbound media by probing dimensions server-side — a much larger change, and the settle window is needed regardless for media whose size cannot be known ahead of time +- Scroll anchoring via CSS `overflow-anchor` — the pane is `flex-col-reverse`, where anchoring behavior is inconsistent across browsers + +## Definition of Done + +- [x] Layer 2 smoke checklist completed +- [x] Layer 3 landing verified +- [x] `pnpm check` clean +- [x] `pnpm typecheck` clean +- [x] `pnpm test` green +- [x] No commented-out code, no `console.log` debris, no AI attribution +- [x] Task closed: status → done, file moved to `docs/tasks/done/`, t184 in commit + +## Notes + +The settle window is time-boxed rather than driven by a load count. A thread can always contain one more image than expected, and an unbounded listener would keep yanking the scroll position long after the user took over. + +Worth remembering: the fix already existed in this file, twenty lines away, with a comment explaining the exact failure. The bug was not a missing insight — it was a fix that never got applied to its sibling. diff --git a/docs/testing/chat-qa.md b/docs/testing/chat-qa.md index d036750..d435096 100644 --- a/docs/testing/chat-qa.md +++ b/docs/testing/chat-qa.md @@ -150,6 +150,11 @@ ones pass. | C-13 | Click the Aa / Formatting toggle, then the Attach button, without moving the caret | Editor keeps focus both times (`.ProseMirror` is still `document.activeElement`) | | C-14 | With an empty selection, click each BIUS chip (B / I / U / S) | Chip's `data-state` flips to `on` immediately (no caret move needed); `editor.isActive('bold'\|'italic'\|'underline'\|'strike')` returns true right after the click | | C-15 | Click each block-format button (inline code, code-block, quote, bullet list, numbered list), then move the caret into/out of that block | Button carries `aria-pressed="true"` + accent bg while the caret is inside the block; flips to `aria-pressed="false"` when the caret leaves | +| C-16 | Compose a message with an @mention AND attach a file, send (t182) | The mention is a real Teams mention on the SENT message — pill renders, `properties.mentions` carries `@type` + full `8:orgid:` mri + `mentionType`. Regression: attaching a file used to silently drop mentions | +| C-17 | Reply-quote a message, attach a file, send (t182) | The quote survives onto the sent message (`qtdMsgs` present); the bubble renders the quoted parent | +| C-18 | Attach 2+ images with a caption + mention, send (t182) | Caption + mention ride exactly ONE of the sends, not all of them and not none | +| C-19 | Same as C-18 but make the FIRST upload fail (t182) | Caption + mention are carried by the next upload that lands — they are not lost with the failed one | +| C-20 | Attach a file with a caption containing an @mention, inspect the sent body (t182) | The mention renders as a pill, NOT as escaped `<span…>` markup (caption HTML rides verbatim) | ### 6. Assistant panel @@ -177,6 +182,13 @@ ones pass. | L-06 | Hover a link until the copy button appears | Copy button appears at the end of the link's last line | | L-07 | Click the copy button | Full URL in clipboard | | L-08 | Switch conversations while hovering a link | Copy overlay dismissed | +| L-09 | Click a Teams `https://teams.microsoft.com/l/message/{convId}/{msgId}` link pointing INTO the open conversation (t183) | Stays in the app — thread jumps to that message with the flash ring; no new tab, no Teams web client | +| L-10 | Click a message link pointing at a DIFFERENT conversation (t183) | That conversation opens and lands on the message; URL becomes `/chat/c/{convId}?msg={msgId}` | +| L-11 | Click a plain external link in a message | Still opens in a new tab — the interception is scoped to message links only | +| L-12 | ⌘-click (or middle-click) an in-app message link | Browser handles it — new tab, no in-app jump (modifier clicks are never swallowed) | +| L-13 | Compare an in-app message link with an external one in the same bubble (t183) | The in-app one is visually distinguishable (dotted underline + `↩` marker) and its `title` reads as a jump, not the raw href | +| L-14 | Select and copy a line containing an in-app message link | The `↩` marker is NOT in the copied text (it's a CSS `::after`) | +| L-15 | Click a message link whose URL contains a malformed `%`-escape | Nothing throws — it falls through as an ordinary external link | ### 8. Conversation list @@ -218,6 +230,15 @@ ones pass. | R-10 | `pnpm test` | All tests pass (currently 1992/1992) | | R-11 | `pnpm typecheck` | Clean | | R-12 | `BIOME_SINCE=origin/main pnpm check:changed` | Clean | +| R-13 | Open a thread containing an uploaded image (t181) | The picture renders INLINE, not as a filename chip; it loads through `/api/chat/media` (`naturalWidth > 0`) | +| R-14 | Click that inline image (t181) | Lightbox opens (`[role="dialog"]`) with the same image source | +| R-15 | Open a message carrying a Loop / Fluid embed (t181) | A chip renders with a working `href` — pre-t181 this message rendered as an empty bubble | +| R-16 | Open a message whose only content is a video (t181) | The `<video>` element is present; the bubble is not blank | +| R-17 | Open a bot message backed by a Swift adaptive card (t181) | The chip shows the card's real title/text, not the bare word "Card" | +| R-18 | Open a forwarded message (t181) | It reads as forwarded (a "Forwarded" label), visually distinct from a reply quote | +| R-19 | Feed a Fluid card whose `componentUrl` is a `javascript:` / `data:` URL (t181) | The chip renders with NO href — the scheme guard drops it | +| R-20 | Jump to a message in a thread full of images that haven't loaded yet (t184) | After the images finish loading the view is STILL on the target message — the landing re-seats for a ~3s settle window instead of drifting | +| R-21 | Fire a second jump while the first is still inside its settle window (t184) | The newest jump wins; the older one stops re-seating | --- diff --git a/scripts/t181-corpus-regression.mjs b/scripts/t181-corpus-regression.mjs new file mode 100644 index 0000000..94d0c80 --- /dev/null +++ b/scripts/t181-corpus-regression.mjs @@ -0,0 +1,109 @@ +// t181 corpus regression: run the captured probe corpus through the real renderer and assert +// every gap class the task set out to close now reports ZERO. +// +// Usage: node scripts/t181-corpus-regression.mjs /path/to/raw_all.json +// The corpus is a raw Teams messages-API dump (read-only probe, not committed — it is live chat data). +import { readFileSync } from "node:fs" +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) +const { toReaderMessages, parseAttachments } = require("../core/teams-render.js") + +const corpusPath = process.argv[2] +if (!corpusPath) { + console.error("usage: node scripts/t181-corpus-regression.mjs <raw_all.json>") + process.exit(2) +} + +const jparse = (v) => { + if (typeof v !== "string") return v + try { + return JSON.parse(v) + } catch { + return null + } +} +const plain = (h) => + String(h || "") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim() + +const raw = JSON.parse(readFileSync(corpusPath, "utf8")) +const rendered = toReaderMessages(raw, "") +const byId = new Map(rendered.map((m) => [String(m.id), m])) + +const fail = {} +const flag = (k, id) => { + if (!fail[k]) fail[k] = [] + fail[k].push(id) +} + +for (const m of raw) { + const id = String(m.id || "") + const content = typeof m.content === "string" ? m.content : "" + const props = m.properties || {} + const r = byId.get(id) + if (!r) continue + const body = r.body || "" + const atts = parseAttachments(m) + + // A — an image upload must render as an inline image, not a filename chip. + for (const f of jparse(props.files) || []) { + const t = String(f?.fileType || "").toLowerCase() + const isImage = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic"].includes(t) + if (!isImage) continue + if (!f?.filePreview?.previewUrl) continue + if (!atts.some((a) => a.kind === "image" && a.thumbnailUrl)) + flag("A image upload still a chip", id) + } + + // B — a Loop/Fluid embed must surface a chip; an auto-embed anchor must carry a label. + const hasFluidCard = (jparse(props.cards) || []).some((c) => + /fluidEmbedCard/i.test(String(c?.contentType || "")), + ) + if (hasFluidCard && !atts.some((a) => a.kind === "card" && a.url)) + flag("B Loop embed dropped", id) + if (/FluidAutoEmbedLink/i.test(content)) { + const empty = /itemtype="[^"]*FluidAutoEmbedLink[^"]*"[^>]*>\s*<\/a>/i.test(body) + if (empty) flag("B FluidAutoEmbedLink still invisible", id) + } + + // C — a video-only message must keep its <video>. + if (/itemtype="[^"]*AMSVideo/i.test(content) && !/<video/i.test(body)) + flag("C video-only body empty", id) + + // D — a SWIFT card must show decoded text, not the generic <Title>. + if (/<Swift\b[^>]*b64="[^"]+"/i.test(content)) { + const card = atts.find((a) => a.kind === "card") + if (!card || (!card.text && /^(Card|New polly!)$/i.test(card.title || ""))) + flag("D Swift card still generic", id) + } + + // E — a forwarded blockquote must be marked. + if (/itemtype="[^"]*Forward/i.test(content) && !/blockquote class="forward"/i.test(body)) + flag("E forward unlabelled", id) + + // Overall: no bubble may render with nothing at all. + if ( + r.kind !== "system" && + !r.deleted && + atts.length === 0 && + plain(body).length === 0 && + !/<img|<video/i.test(body) + ) + flag("blank bubble", id) +} + +console.log(`corpus: ${raw.length} raw → ${rendered.length} rendered\n`) +const keys = Object.keys(fail) +if (keys.length === 0) { + console.log("all t181 gap classes report 0 — PASS") + process.exit(0) +} +for (const k of keys) + console.log( + `${String(fail[k].length).padStart(5)} ${k}\n e.g. ${fail[k].slice(0, 3).join(", ")}`, + ) +process.exit(1) diff --git a/scripts/t181-mutation-check.mjs b/scripts/t181-mutation-check.mjs new file mode 100644 index 0000000..012dfef --- /dev/null +++ b/scripts/t181-mutation-check.mjs @@ -0,0 +1,99 @@ +// t181 mutation check: revert each production change in turn and assert the matching test goes RED. +// A green suite that is not actually wired to the behaviour it names is worse than no suite. +import { execSync } from "node:child_process" +import { readFileSync, writeFileSync } from "node:fs" + +const SRC = "core/teams-render.js" +const original = readFileSync(SRC, "utf8") + +const mutants = [ + { + name: "P1-C hasVisibleText drops <video>", + from: "if (/<(?:img|video)\\b/i.test(html)) return true", + to: "if (/<img\\b/i.test(html)) return true", + expect: "video-only body", + }, + { + name: "P1-A image upload never becomes kind:image", + from: "if (IMAGE_FILE_TYPES.has(type.toLowerCase()) && isValidAmsUrl(preview)) {", + to: "if (false && preview) {", + expect: "image uploads", + }, + { + name: "P1-A SSRF gate removed (any preview host accepted)", + from: "IMAGE_FILE_TYPES.has(type.toLowerCase()) && isValidAmsUrl(preview)", + to: "IMAGE_FILE_TYPES.has(type.toLowerCase()) && !!preview", + expect: "SSRF gate holds", + }, + { + name: "P2-A Swift payload never decoded", + from: "const decoded = swiftCardText(m[2])", + to: "const decoded = null", + expect: "Swift adaptive card", + }, + { + name: "P2-A card text cap removed", + from: 'return { title: title.slice(0, 120), text: rest.join(" · ").slice(0, CARD_TEXT_CAP) }', + to: 'return { title: title.slice(0, 120), text: rest.join(" · ") }', + expect: "caps runaway card text", + }, + { + name: "P1-B Loop cards not parsed", + from: 'if (!/fluidEmbedCard/i.test(String(c?.contentType || ""))) continue', + to: "continue", + expect: "Loop / Fluid embed", + }, + { + name: "P1-B Loop componentUrl scheme check removed", + from: 'if (typeof url !== "string" || !/^https?:\\/\\//i.test(url)) continue', + to: 'if (typeof url !== "string") continue', + expect: "not http(s)", + }, + { + name: "P1-B FluidAutoEmbedLink not labelled", + from: "labelFluidLinks(tagEmoji(", + to: "((x) => x)(tagEmoji(", + expect: "FluidAutoEmbedLink", + }, + { + name: "P2-B Forward blockquote not labelled", + from: "labelForwards(\n", + to: "((x) => x)(\n", + expect: "forwarded blocks", + }, + { + name: "P2-B Forward label reverts to sighted-only (no DOM node)", + from: '<span class="forward-label">Forwarded</span>', + to: "", + expect: "screen readers reach it", + }, +] + +let survived = 0 +for (const m of mutants) { + if (!original.includes(m.from)) { + console.log(`SKIP ${m.name} — anchor not found (rewrite the mutant)`) + survived++ + continue + } + writeFileSync(SRC, original.replace(m.from, m.to)) + let red = false + let out = "" + try { + out = execSync("npx vitest run core/teams-render.test.ts --reporter=dot 2>&1", { + encoding: "utf8", + stdio: "pipe", + }) + } catch (e) { + red = true + out = (e.stdout || "") + (e.stderr || "") + } + const hit = out.includes(m.expect) + console.log( + `${red ? "KILLED" : "SURVIVED"} ${m.name}${red && !hit ? " (!! red, but not in the expected suite)" : ""}`, + ) + if (!red) survived++ +} +writeFileSync(SRC, original) +console.log(`\n${mutants.length - survived}/${mutants.length} mutants killed`) +process.exit(survived === 0 ? 0 : 1) diff --git a/scripts/t182-t183-mutation-check.mjs b/scripts/t182-t183-mutation-check.mjs new file mode 100644 index 0000000..35a561d --- /dev/null +++ b/scripts/t182-t183-mutation-check.mjs @@ -0,0 +1,207 @@ +// t182/t183 mutation check: revert each production change in turn and assert the matching test goes +// RED. A green suite that is not actually wired to the behaviour it names is worse than no suite. +// +// t184 (jump landing settle) has no pure core to mutate — it is DOM scroll behaviour, verified by +// manual smoke instead. See docs/tasks/done/184-jump-landing-media-settle.md. +import { execSync } from "node:child_process" +import { readFileSync, writeFileSync } from "node:fs" + +const mutants = [ + // ---- t182: attachment mentions + quotes ------------------------------------------------- + { + src: "core/teams-send-props.js", + test: "core/teams-send-props.test.ts", + name: "t182 mention entries lose the load-bearing @type", + from: '"@type": "http://schema.skype.com/Mention",', + to: "", + expect: "five load-bearing fields", + }, + { + src: "core/teams-send-props.js", + test: "core/teams-send-props.test.ts", + name: "t182 mention entries lose mentionType", + from: 'mentionType: "person",', + to: "", + expect: "five load-bearing fields", + }, + { + src: "core/teams-send-props.js", + test: "core/teams-send-props.test.ts", + name: "t182 mentions sent as an array instead of a JSON string", + from: "properties.mentions = JSON.stringify(", + to: "properties.mentions = (", + expect: "serializes mentions", + }, + { + src: "core/teams-send-props.js", + test: "core/teams-send-props.test.ts", + name: "t182 mri-less mention forwarded instead of dropped", + from: "const usable = mentions.filter((m) => m?.mri)", + to: "const usable = mentions", + expect: "notifies nobody", + }, + { + src: "core/teams-send-props.js", + test: "core/teams-send-props.test.ts", + name: "t182 quotes lose hasValidMsgReferences", + from: "properties.hasValidMsgReferences = true", + to: "", + expect: "quoted reply", + }, + { + src: "core/teams-send-props.js", + test: "core/teams-send-props.test.ts", + name: "t182 caption html escaped instead of sent verbatim (kills mention spans)", + from: "if (html && String(html).trim()) return html", + to: "if (false) return html", + expect: "mention spans survive", + }, + { + src: "core/teams-send-props.js", + test: "core/teams-send-props.test.ts", + name: "t182 extra properties (the file chip payload) dropped", + from: "return { ...properties, ...extra }", + to: "return properties", + expect: "file chip", + }, + { + src: "core/teams-ams.js", + test: "core/teams-ams.test.ts", + name: "t182 AMS caption html ignored (mention spans get escaped)", + from: "if (captionHtml && String(captionHtml).trim()) return `${captionHtml}<br>`", + to: "if (false) return ``", + expect: "verbatim", + }, + + // ---- t183: message link resolution ------------------------------------------------------ + { + src: "chat/src/lib/message-url.ts", + test: "chat/src/lib/message-url.test.ts", + name: "t183 host allowlist becomes a suffix check (lookalike host accepted)", + from: "return TEAMS_HOSTS.some((h) => host === h || host.endsWith(`.${h}`))", + to: "return TEAMS_HOSTS.some((h) => host.endsWith(h))", + expect: "lookalike host", + }, + { + src: "chat/src/lib/message-url.ts", + test: "chat/src/lib/message-url.test.ts", + name: "t183 subdomain match dropped (regional hosts stop resolving)", + from: "return TEAMS_HOSTS.some((h) => host === h || host.endsWith(`.${h}`))", + to: "return TEAMS_HOSTS.some((h) => host === h)", + expect: "genuine subdomain", + }, + { + src: "chat/src/lib/message-url.ts", + test: "chat/src/lib/message-url.test.ts", + name: "t183 scheme guard removed (ftp: reaches the handler)", + from: 'if (url.protocol !== "https:" && url.protocol !== "http:") return null', + to: "", + expect: "non-http(s) schemes", + }, + { + src: "chat/src/lib/message-url.ts", + test: "chat/src/lib/message-url.test.ts", + name: "t183 same-origin check removed for /chat/c/ links", + from: "if (url.origin === origin) {", + to: "if (true) {", + expect: "different origin", + }, + { + src: "chat/src/lib/message-url.ts", + test: "chat/src/lib/message-url.test.ts", + name: "t183 message id no longer required to be numeric", + from: "if (!convId || !msgId || !/^\\d+$/.test(msgId)) return null", + to: "if (!convId || !msgId) return null", + expect: "not digits", + }, + { + src: "chat/src/lib/message-url.ts", + test: "chat/src/lib/message-url.test.ts", + name: "t183 percent-encoded conversation id left encoded", + from: "return target(decodePath(m[1]), decodePath(m[2]))", + to: "return target(m[1], m[2])", + expect: "percent-encoded", + }, + { + src: "chat/src/lib/message-url.ts", + test: "chat/src/lib/message-url.test.ts", + name: "t183 malformed percent-escape throws out of the click handler", + from: " try {\n return decodeURIComponent(segment)\n } catch {\n return null\n }", + to: " return decodeURIComponent(segment)", + expect: "malformed percent-encoding", + }, + { + src: "chat/src/lib/message-url.ts", + test: "chat/src/lib/message-url.test.ts", + name: "t183 a chat link with no ?msg= resolves to a bogus target", + from: 'const msgId = url.searchParams.get("msg")\n if (!msgId) return null', + to: 'const msgId = url.searchParams.get("msg") || "0"', + expect: "no ?msg=", + }, + + // ---- t182/t183 follow-ups from the polish pass ------------------------------------------- + { + src: "chat/src/lib/send-chain.ts", + test: "chat/src/lib/send-chain.test.ts", + name: "t182 caption keyed on truthiness, so an empty server id resends it", + from: "return landed === null", + to: "return !landed", + expect: "empty id as landed", + }, + { + src: "chat/src/lib/message-link-affordance.ts", + test: "chat/src/lib/message-link-affordance.test.ts", + name: "t183 every anchor tagged as in-app, not just resolvable ones", + from: "if (!target) continue", + to: "if (false) continue", + expect: "external link alone", + }, + { + src: "chat/src/lib/message-link-affordance.ts", + test: "chat/src/lib/message-link-affordance.test.ts", + name: "t183 jump hint overwrites an author-supplied title", + from: 'if (!a.hasAttribute("title")) a.setAttribute("title", "Open this message in Chats")', + to: 'a.setAttribute("title", "Open this message in Chats")', + expect: "existing title", + }, +] + +const originals = new Map() +const read = (p) => { + if (!originals.has(p)) originals.set(p, readFileSync(p, "utf8")) + return originals.get(p) +} +const restore = () => { + for (const [p, text] of originals) writeFileSync(p, text) +} + +let survived = 0 +for (const m of mutants) { + const original = read(m.src) + if (!original.includes(m.from)) { + console.log(`SKIP ${m.name} — anchor not found (rewrite the mutant)`) + survived++ + continue + } + writeFileSync(m.src, original.replace(m.from, m.to)) + let red = false + let out = "" + try { + out = execSync(`npx vitest run ${m.test} --reporter=dot 2>&1`, { + encoding: "utf8", + stdio: "pipe", + }) + } catch (e) { + red = true + out = (e.stdout || "") + (e.stderr || "") + } + writeFileSync(m.src, original) + const hit = out.includes(m.expect) + console.log( + `${red ? "KILLED " : "SURVIVED"} ${m.name}${red && !hit ? " (!! red, but not in the expected suite)" : ""}`, + ) + if (!red) survived++ +} +restore() +console.log(`\n${mutants.length - survived}/${mutants.length} mutants killed`) +process.exit(survived === 0 ? 0 : 1) diff --git a/web/server.mjs b/web/server.mjs index d8adaac..43e2c39 100644 --- a/web/server.mjs +++ b/web/server.mjs @@ -71,6 +71,7 @@ import { quoteAuthorMris as teamsQuoteAuthorMris, toReaderMessages as teamsToReaderMessages, } from "../core/teams-render.js" +import { buildSendProperties, captionHtml } from "../core/teams-send-props.js" import { CLEARED_UNREAD_BOOKMARK, getUsers as teamsGetUsers, @@ -1904,42 +1905,9 @@ async function teamsReply(convId, text, html, quoteRefs = [], mentions = []) { const content = html || text const messagetype = html ? "RichText/Html" : "Text" - // A quoted reply carries `qtdMsgs` (+ formatVariant/hasValidMsgReferences) so Teams renders it as a - // native reply, not just inline blockquote markup (PSN-92, live-verified against a real reply's wire). - const properties = {} - if (quoteRefs.length) { - properties.qtdMsgs = quoteRefs.map((q) => ({ - messageId: q.messageId, - sender: q.sender, - time: q.time, - message: null, - validationResult: "Valid", - sharedRefId: null, - replyChainId: null, - })) - properties.formatVariant = "TEAMS" - properties.hasValidMsgReferences = true - } - // @mentions ride as a JSON-STRING `properties.mentions` (per-token, live-verified) so recipients - // get a real mention (PSN-92 D). Teams' own wire keeps this as a string, not a nested array. - // - // `@type` + `mentionType` are LOAD-BEARING, not decoration: native Teams stamps both on every - // entry, and an entry missing either is stored fine (201) yet mentions nobody — Teams renders the - // raw per-token spans and never fans the message into the recipient's mention feed. Proven live - // (PSN-120, scripts/mention-spike.mjs) against `48:mentions`, the service-side oracle: entries with - // an mri alone did NOT register; entries carrying all three did. Same for a bare-oid `mri` — it - // must be the full `8:orgid:{oid}` MRI. - if (mentions.length) { - properties.mentions = JSON.stringify( - mentions.map((m) => ({ - "@type": "http://schema.skype.com/Mention", - itemid: m.itemid, - mri: m.mri, - mentionType: "person", - displayName: m.displayName, - })), - ) - } + // Quote + mention wire shape lives in core/teams-send-props.js — shared with the upload paths so + // an attachment send carries them too (t182). + const properties = buildSendProperties({ quotes: quoteRefs, mentions }) let out = await sendTeamsMessageInPage(cred, convId, content, messagetype, properties) if (out.error === "invalid_auth") { await notificationCenter.markTeamsCredsStale(cred.tenant, "invalid_auth") @@ -2252,7 +2220,17 @@ async function createTeamsAmsObjectInPage(convId, filename, base64) { // Mint/reuse creds → in-page AMS create+upload → build the AMSImage content → in-page send. A 401 on // either half drives one re-authz + retry, then a hard typed invalid_auth (mirrors teamsReply). // Returns { ok, msgId } (msgId = OriginalArrivalTime, which is the message id/ts). -async function teamsUploadImage({ convId, filename, base64, width, height, text }) { +async function teamsUploadImage({ + convId, + filename, + base64, + width, + height, + text, + html, + quotes = [], + mentions = [], +}) { let cred = notificationCenter.listTeamsCreds().find((c) => c.fresh !== false) if (!cred) { await notificationCenter.refreshTeamsCreds() @@ -2260,6 +2238,9 @@ async function teamsUploadImage({ convId, filename, base64, width, height, text } if (!cred) return { error: "invalid_auth" } + // Quotes/mentions ride the same properties payload as a text reply (t182) — before that they were + // silently dropped whenever the send carried a file. + const properties = buildSendProperties({ quotes, mentions }) let content = null const run = async () => { const ams = await createTeamsAmsObjectInPage(convId, filename, base64) @@ -2270,8 +2251,9 @@ async function teamsUploadImage({ convId, filename, base64, width, height, text width, height, caption: text, + captionHtml: html, }) - return await sendTeamsMessageInPage(cred, convId, content, "RichText/Html") + return await sendTeamsMessageInPage(cred, convId, content, "RichText/Html", properties) } let out = await run() @@ -2307,7 +2289,7 @@ async function teamsUploadImage({ convId, filename, base64, width, height, text // Each entry in `images` is { filename, base64, width, height }. All AMS uploads run in parallel; // if any fails the whole call returns its error. The combined body is ONE RichText/Html message. // Falls back to sequential single-image sends in the caller when this returns a non-ok response. -async function teamsUploadImagesMulti({ convId, images, text }) { +async function teamsUploadImagesMulti({ convId, images, text, html, quotes = [], mentions = [] }) { let cred = notificationCenter.listTeamsCreds().find((c) => c.fresh !== false) if (!cred) { await notificationCenter.refreshTeamsCreds() @@ -2315,6 +2297,7 @@ async function teamsUploadImagesMulti({ convId, images, text }) { } if (!cred) return { error: "invalid_auth" } + const properties = buildSendProperties({ quotes, mentions }) let content = null const run = async () => { // Upload all images in parallel — fail fast on the first error. @@ -2329,8 +2312,8 @@ async function teamsUploadImagesMulti({ convId, images, text }) { width: images[i].width, height: images[i].height, })) - content = buildAmsImageContentMulti(imgParams, text) - return await sendTeamsMessageInPage(cred, convId, content, "RichText/Html") + content = buildAmsImageContentMulti(imgParams, text, html) + return await sendTeamsMessageInPage(cred, convId, content, "RichText/Html", properties) } let out = await run() @@ -2428,7 +2411,15 @@ async function uploadTeamsFileInPage(filename, base64) { // Mint/reuse creds → in-page SharePoint upload+share → build the file descriptor → in-page send with // properties.files. A 401 on either half drives one re-authz + retry, then a hard typed invalid_auth // (mirrors teamsUploadImage). Returns { ok, msgId } (msgId = OriginalArrivalTime = the message id/ts). -async function teamsUploadFile({ convId, filename, base64, text }) { +async function teamsUploadFile({ + convId, + filename, + base64, + text, + html, + quotes = [], + mentions = [], +}) { let cred = notificationCenter.listTeamsCreds().find((c) => c.fresh !== false) if (!cred) { await notificationCenter.refreshTeamsCreds() @@ -2437,7 +2428,8 @@ async function teamsUploadFile({ convId, filename, base64, text }) { if (!cred) return { error: "invalid_auth" } // A caption rides as the message content (RichText/Html) above the file chip; empty → no bubble. - const caption = text && String(text).trim() ? escapeHtml(text).replace(/\n/g, "<br>") : "" + // Pre-built HTML wins verbatim so @mention spans survive (t182). + const caption = captionHtml({ text, html }) const run = async () => { const up = await uploadTeamsFileInPage(filename, base64) if (up.error) return up @@ -2448,9 +2440,17 @@ async function teamsUploadFile({ convId, filename, base64, text }) { shareUrl: up.shareUrl, filename, }) - return await sendTeamsMessageInPage(cred, convId, caption, "RichText/Html", { - files: JSON.stringify([fileObj]), - }) + return await sendTeamsMessageInPage( + cred, + convId, + caption, + "RichText/Html", + buildSendProperties({ + quotes, + mentions, + extra: { files: JSON.stringify([fileObj]) }, + }), + ) } let out = await run() @@ -3162,8 +3162,11 @@ const server = http.createServer(async (req, res) => { return ijson(res, await teamsUploadImage(await ibody(req))) } if (iop === "upload-images" && POST) { - const { convId, images, text } = await ibody(req) - return ijson(res, await teamsUploadImagesMulti({ convId, images, text })) + const { convId, images, text, html, quotes, mentions } = await ibody(req) + return ijson( + res, + await teamsUploadImagesMulti({ convId, images, text, html, quotes, mentions }), + ) } if (iop === "upload-file" && POST) { return ijson(res, await teamsUploadFile(await ibody(req)))