Skip to content

Preserve media embedded in forecast discussions and avalanche problems - #1228

Merged
busbyk merged 6 commits into
native-product-pagesfrom
busbyk/embedded-media
Aug 26, 2026
Merged

Preserve media embedded in forecast discussions and avalanche problems#1228
busbyk merged 6 commits into
native-product-pagesfrom
busbyk/embedded-media

Conversation

@busbyk

@busbyk busbyk commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Description

Forecaster-authored HTML can embed media, and the native forecast page was dropping it: sanitizeHtml allowed img but not iframe, so a pasted YouTube or Facebook video was deleted with no trace, and the data-video-id marking an embedded-video figure went with it — leaving a poster indistinguishable from a photo. A video attached to an avalanche problem rendered as nothing at all, because problemPhoto returned null for MediaType.Video. This restores what the legacy widget does, on AvyWeb's design system.

I sampled 6,745 v2 products across all 29 centers' full history rather than guessing at the markup: 2,259 afp-photoswipe figures, 40 iframes (36 YouTube, 4 Facebook), 2 afp-video-modal figures, and — separately — 111 problem-level videos among the 1,836 problems that carry media, all of them YouTube.

Related Issues

Closes #1214 — Preserve discussion-embedded media (videos and zoomable images). Inventory row D3.

Also fixes the same defect on a problem's example media, which had no issue of its own.

Key Changes

  • Sanitizer: iframe allowed behind an explicit provider allowlist (YouTube/Vimeo via the existing videoEmbed helpers, plus Facebook's video.php/post.php over https), sized by aspect ratio rather than the authored pixel height. allowedIframeHostnames isn't enough on its own — it only deletes the src and leaves an empty frame — so the check lives in transformTags.
  • A blocked embed leaves an inert note naming the provider instead of vanishing, and nonTextTags discards its children: an <iframe>'s children are fallback a browser never renders, but the parser reads them as live markup.
  • Inline height is stripped from every forecast image, matching the widget's .tinymce-img { height: auto !important }.
  • Discussion: collectEmbeddedMedia finds embedded figures in the rendered DOM and feeds the existing MediaLightbox. Reading the DOM rather than splitting the HTML matters — 13 of 2,261 real figures are nested, and one center wraps its whole discussion in a div, so nothing there is ever top-level.
  • Problem media: video renders its poster with a play button and opens in the lightbox; photos are zoomable rather than static. getYouTubeVideoId now also reads the bare-string shape, which four SNFAC forecasts use and which previously showed "Unsupported media type".

How to test

pnpm test covers the sanitizer (28 cases, fixtures are verbatim markup from the live v2 API), the DOM collector, and the rendering. By hand, on a center with the native forecast flag on: an embedded photo shows an expand chip and opens zoomable; an embedded video shows a play button and plays in the lightbox; a pasted YouTube/Facebook iframe plays inline; right-clicking a poster still opens the image's own context menu, since only the small chip is a button.

Screenshots / Demo video

All four are real markup from the live v2 API, rendered on a local tenant.

A discussion with embedded figures. The chart carries an expand chip in its bottom-left corner; the video below it shows a play button. Before this change both rendered as plain static images, and the video was indistinguishable from a photo — its data-video-id was stripped by the sanitizer, so there was no way to tell it was a video or to watch it. Sources: BTAC 184960 (chart), SNFAC 109444 (video).

01-discussion-embedded-figures

A pasted YouTube iframe, playing inline. The sanitizer allowed img but not iframe, so this was deleted outright with no trace. Below it, an embed from a provider we won't frame now leaves an inert note naming the provider rather than vanishing. Source: TAC 181191.

02-inline-youtube-and-blocked-embed

The same discussion video in the shared lightbox, with its caption, matching what the legacy widget does on click. Source: SNFAC 109444.

03-discussion-video-lightbox

A video attached to an avalanche problem. This rendered as nothing at all before — no poster, no caption, no sign a forecaster had attached one. 111 of the 1,836 problems that carry media are videos, including recent CNFAIC, SNFAC and NWAC forecasts. Source: BAC 127432.

04-problem-media

No console errors in any of these. They're viewport captures rather than full-page ones because Chrome blanks cross-origin iframes in full-page screenshots.

Migration Explanation

None — no schema change.

Future enhancements / Questions

Deliberate divergences from the widget, worth a look:

  • Each surface has its own lightbox. The widget keeps one continuous list spanning problem media → discussion → gallery. Unifying them means hoisting lightbox state above AvalancheProblemCard, which would pull sanitize-html into the client bundle.
  • YouTube frames go to youtube-nocookie.com with rel=0, which is what getVideoEmbedUrl produces and what the Gallery block already ships. This also changes MediaSlide; two existing assertions were updated.
  • A fixed allow policy replaces whatever was authored (32 of the 40 real iframes authored none). No sandbox: the src is one of a handful of vetted provider URLs, and a wrong flag fails as a blank frame.

Left alone, each worth its own issue: align-center on a figure does nothing here (22 real figures; the widget centers them), and WarningBanner renders forecaster HTML that the widget escapes with {{ }} — pre-existing, now slightly wider.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

busbyk and others added 6 commits August 23, 2026 16:45
The forecast sanitizer allowed `img` but not `iframe`, so a YouTube or Facebook
video a forecaster embedded in a discussion was deleted with no trace, and the
`data-video-id` that marks an embedded-video figure was stripped along with it.
On a life-safety page, dropping a forecaster's video silently is the wrong
failure.

Allow `iframe` behind an explicit provider allowlist: YouTube and Vimeo through
the existing `videoEmbed` helpers, plus Facebook's `/plugins/video.php` and
`/plugins/post.php` over https. `allowedIframeHostnames` is not enough on its
own — it deletes the `src` and leaves an empty frame — so the check lives in
`transformTags`, which renames anything that fails to a tag outside the allowlist.
Surviving frames get a fixed attribute set and are sized by aspect ratio rather
than by the authored pixel height, which letterboxes once the column is narrower
than the author's screen. An embed from a provider we won't frame becomes a link
labelled with its hostname rather than disappearing.

Also strip inline `height` from every forecast image. The declared width is
honored up to the column width, and a surviving pixel height then stretches the
image; the legacy widget forced `height: auto !important` on all of them for the
same reason.

`MediaSlide` now builds its YouTube URL with `getVideoEmbedUrl` too, so the same
video frames the same way inline and in the lightbox. That moves the lightbox to
youtube-nocookie.com with `rel=0`, matching the Gallery block.

Fixtures in the tests are verbatim markup from the live v2 API.

Refs #1214

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Photos a forecaster embeds in a discussion rendered as static images: no expand
affordance, not zoomable, and a video figure — a poster image carrying a
`data-video-id` — was indistinguishable from a photo with no way to watch it.
The legacy widget put both into its lightbox.

`collectEmbeddedMedia` finds those figures in the rendered DOM and builds the
same `MediaItem` shape the widget built, so they can go through the existing
`MediaLightbox`. Reading the DOM rather than splitting the HTML string is what
the widget did, and the corpus says why it matters: of 2,261 real figures, 13
are nested inside a wrapper or another figure's `figcaption`, and one center
wraps its whole discussion in a `div`, so nothing there is ever top-level.
Querying the tree finds all 2,261.

`DiscussionBody` still renders the discussion in one `dangerouslySetInnerHTML`,
so server rendering and the no-JS rendering are unchanged, then marks up the
figures after mount and portals in the legacy affordances — an expand chip, and
a play button on videos. The rendered HTML sits behind `memo` because React
compares the `dangerouslySetInnerHTML` wrapper by identity: re-rendering it, as
opening the lightbox does, would rewrite the subtree and strand every overlay in
a detached node.

Only the small chip is a button. The rest of the overlay is `pointer-events:
none`, as the legacy icons were, so an image keeps its own context menu; mouse
users open the lightbox by clicking anywhere on the figure. Being a button is
also what makes the figure keyboard-reachable — the legacy widget was click-only.

Refs #1214

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getVideoEmbedUrl` rebuilds the URL from the video id alone, which is fine for a
plain video and wrong for a playlist: `/embed/videoseries?list=…` loses its list
and, because `videoseries` is itself eleven characters, sails through the video-id
check and asks YouTube for a video by that name — a broken player, with no sign
anything went wrong. A start offset and a Vimeo private-video hash disappear the
same way, more quietly.

Carry `list`, `index`, `start`, `end`, `t` and `h` onto the rebuilt URL. The
`si` share token still goes.

Nothing in the corpus embeds a playlist today; this is about the next one.

Refs #1214

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A browser never renders an `<iframe>`'s children — they are fallback for clients
that can't frame — but htmlparser2 reads them as live markup. A blocked embed was
being renamed to a real `<a>`, and `nonTextTags` only governs tags the sanitizer
disallows, so those children survived: `<iframe src="https://evil/x"><figure
class="afp-photoswipe">…` came out the other side as a figure, which
`collectEmbeddedMedia` then queries for and puts in the lightbox. Content the
legacy widget would never have shown, reaching a life-safety page.

Keep the blocked embed on the dropped tag, which `nonTextTags` does govern, and
carry the note as that transform's `text` — sanitize-html emits a discarded tag's
text while discarding its children.

The note is now inert rather than a link. The URL just failed the allowlist; the
reader should know something was there without being one click from it.

Refs #1214

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`problemPhoto` returned null for anything that wasn't an image or a photo, so a
video attached to an avalanche problem rendered as nothing at all — no poster, no
caption, no indication a forecaster had put one there. The legacy widget shows the
poster frame with a play glyph over it and opens it in the lightbox.

Across 4,545 sampled products, 1,836 problems carry media and 111 of those are
videos, including recent CNFAIC, SNFAC and NWAC forecasts. All of them are
YouTube: 107 as an object with a `video_id` and its own poster frame, and 4 —
SNFAC, 2020 — where `url` is a bare string that is itself the YouTube id.

Handle both, and route the media through the lightbox, so a problem's example
photo is zoomable too rather than a static image.

`getYouTubeVideoId` now reads the bare-string shape, matching the legacy widget's
`image.url?.video_id ? … : "…/embed/" + image.url`. That shape reached
`resolveMediaSlide` as "Unsupported media type" before, so this also fixes those
four in the forecast media lightbox. A video with no poster of its own falls back
to YouTube's; the legacy widget has a helper for that but never calls it, and
renders a broken image instead.

The expand/play affordance moves to a shared MediaOverlay, now that the discussion
and the problem card both draw it. The jsdom stubs both of their tests need move
to a shared client setup file rather than being copied per suite.

Refs #1214

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/embed/live_stream?channel=…` fails exactly like the playlist case: `live_stream`
is eleven characters, so it passes for a video id and the rebuilt URL drops the
channel it was meant to play.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Preview deployment: https://busbykxembedded-media.preview.avy-fx.org

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

@busbyk
busbyk merged commit bc60403 into native-product-pages Aug 26, 2026
7 checks passed
@busbyk
busbyk deleted the busbyk/embedded-media branch August 26, 2026 19:56
busbyk added a commit that referenced this pull request Aug 26, 2026
#1228 changed where a forecast page goes for a video. `getVideoEmbedUrl`
frames YouTube at `youtube-nocookie.com` rather than `youtube.com`, a video
poster now comes from `i.ytimg.com`, and the sanitizer lets a forecaster's own
`iframe` through for Vimeo and Facebook as well.

`stubExternalAssets` still routed only `www.youtube.com`. Nothing fails when a
host is missing from that list — the suite just quietly stops being hermetic and
starts loading the real provider, which is the failure the whole harness is built
to rule out.

The lightbox's YouTube assertion moved for the same reason: it matched
`youtube.com/embed/`, which the nocookie host no longer contains. It is skipped
pending a fixture, so it would have gone stale unnoticed until the capture
landed and then failed for a reason unrelated to the capture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
busbyk added a commit that referenced this pull request Aug 26, 2026
#1228 restored four things the native forecast page was dropping: an embedded
photo is zoomable, an embedded video plays, a pasted provider iframe frames
inline, and an embed we will not frame leaves a note naming the provider. None
of it is reachable from the corpus — no golden's authored HTML carries a
`figure` or an `iframe` at all — so the specs are written and skip themselves
with the Case they wait on, as the rest of the blocked suite does.

That capture is a different kind of gap from the ones already listed, and the
doc now says so: the others are wire-level, so any product of the right shape
will do, while this one is about markup a forecaster typed, which no schema
requires. It belongs with the shape-variant work in #1209.

One test here is not about embedding. `DiscussionBody` delegates clicks from the
whole discussion subtree, so a link inside it is the one thing that has to
escape the handler rather than open the lightbox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
busbyk added a commit that referenced this pull request Aug 26, 2026
CI already runs this suite on every PR, so a change that breaks an existing
spec fails on its own. What CI cannot say is that a surface added yesterday has
no spec at all — which is how #1228 shipped four new reader-facing behaviours,
each well covered by unit tests, with nothing here.

Two failure modes are worth naming because neither shows up red: a third-party
host missing from `stubExternalAssets` loads for real and the suite still
passes, and an assertion inside a skipped spec is still asserting whatever was
true the day it was written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant