Skip to content

Open linked images in-app on web and desktop - #688

Merged
bill-long merged 9 commits into
mainfrom
codex/linked-image-viewer
Sep 17, 2026
Merged

bill-long merged 9 commits into
mainfrom
codex/linked-image-viewer

Conversation

@bill-long

@bill-long bill-long commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

Clicking a direct HTTPS image link in a message or its preview opens the existing full-screen image viewer on web and desktop. The viewer provides zoom, fit, Escape/focus restoration, and Open in browser. Ctrl/Cmd/Shift/Alt and middle clicks retain normal link navigation. Navigation closes the viewer; a recycled opener returns focus to its surviving context.

Image detection uses the URL pathname suffix (JPG/JPEG, PNG, GIF, WebP, AVIF), independently of preview metadata. The original image is requested only after the click. A failed or non-image response uses the existing error surface and retains the external link. HTTP images retain external navigation under the production CSP. Preview cards keep their original single-anchor structure and layout; ordinary article/video previews remain external links. Linked images use Open in browser rather than a cross-origin download fetch; attachment downloads keep their existing behavior.

Desktop external links use Tauri's scoped opener after in-app routing, fixing the silent desktop click. Validation: lint, typecheck, 4,258 unit tests, 168 browser tests, production build, and cargo check --locked. A separate live browser check loaded the reported EVE JPG, verified viewport fit, external navigation URL, and Escape. CI tests use intercepted fixture media and a mocked native bridge; the installed desktop executable has not been replaced by this PR build.

Copilot AI lite review requested due to automatic review settings September 16, 2026 19:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Preserve external handling for matrix: and other supported custom schemes in desktop link routing.

Pull request overview

Adds in-app linked-image viewing for web and desktop, plus scoped Tauri handling for external links.

Changes:

  • Adds linked-image detection, caching, fullscreen viewing, retry, and focus restoration.
  • Separates preview-thumbnail clicks from article navigation.
  • Adds Tauri opener integration, permissions, and regression tests.
File summaries
File Description
src/stores/linkedImage.ts Stores linked-image viewer state.
src/lib/imageLink.ts Detects supported image URLs and sources.
src/lib/imageLink.test.ts Tests image URL handling.
src/features/room/urlPreviews/UrlPreviewCard.tsx Separates thumbnail viewing from title navigation.
src/features/room/urlPreviews/previewCache.ts Reuses cached preview data.
src/features/room/urlPreviews/LinkedImageViewer.tsx Provides the global linked-image viewer.
src/features/room/urlPreviews/LinkedImageViewer.browser.test.tsx Tests viewer and desktop flows.
src/features/room/timeline/ImageLightbox.tsx Supports external URLs, retries, and encrypted media.
src/features/room/timeline/ImageLightbox.test.tsx Tests lightbox behavior.
src/app/Layout.tsx Mounts the linked-image viewer.
src/app/externalLinks.ts Routes desktop external links through Tauri.
src/app/externalLinks.test.ts Tests external-link routing.
src/app/App.tsx Installs external-link delegation.
desktop/src-tauri/src/lib.rs Registers the opener plugin.
desktop/src-tauri/Cargo.toml Adds the opener dependency.
desktop/src-tauri/Cargo.lock Locks opener dependencies.
desktop/src-tauri/capabilities/default.json Scopes permitted external URL schemes.
Review details

Suppressed comments (1)

src/app/externalLinks.ts:8

  • PermalinkRouting deliberately leaves modified/non-left matrix: URIs to their external behavior, but this allowlist rejects every non-HTTP(S)/mailto scheme. With open_js_links_on_click(false) in the new opener plugin, Ctrl/Cmd- or middle-clicking a matrix: link has no Tauri opener path and can stay in the desktop webview instead of opening externally. Handle the Matrix/custom schemes with matching scoped permissions, or preserve the plugin/default handling for them.
		return ["http:", "https:", "mailto:"].includes(url.protocol) ? url : null;
  • Files reviewed: 16/17 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Copilot AI review requested due to automatic review settings September 16, 2026 19:20
@bill-long

Copy link
Copy Markdown
Owner Author

Addressed the suppressed custom-scheme finding from review #5227193215 in 3f321ea. Desktop external routing now includes the supported Matrix message-link schemes (matrix:, tel:, xmpp:, geo:, and magnet:), with matching scoped native permissions. In-app Matrix routing still takes priority. Tests cover modified/middle clicks, already-handled clicks, and parity between the frontend protocol gate and native permissions. mxc: remains a media identifier rather than an external application protocol.

The same commit fixes a local-review finding about article-preview downloads: image filenames now derive their extension from the fetched image type rather than the webpage path.

Validation: lint, typecheck, 4,267 unit tests, 163 browser tests, production build, cargo check --locked, and a fresh local Codex review with no actionable findings. Requesting review of the updated HEAD.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Fix external-image downloads and close the viewer when navigation changes only the URL fragment.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

src/features/room/urlPreviews/LinkedImageViewer.tsx:15

  • This close effect only subscribes to pathname and search. A navigation that changes only the URL fragment will leave linkedImage set and the lightbox open, even though useLocation() exposes hash and the app preserves it as part of navigation state (for example in src/app/Layout.tsx:960). Read location.hash here as well so every URL navigation closes the viewer.

src/features/room/urlPreviews/LinkedImageViewer.tsx:61

  • For an uncached direct link, fullUrl is the third-party image URL, but ImageLightbox downloads plain images with fetch(img.fullUrl, { credentials: "omit" }). A cross-origin <img> can render without CORS while that fetch is blocked by most image hosts, so the existing Download action fails for otherwise viewable linked images. Please route this download through a same-origin/server proxy, or hide/replace the download action for direct external URLs and keep “Open in browser” as the fallback.
			eventId: current.sourceUrl,
			fullUrl: current.fullUrl,
			externalUrl: current.sourceUrl,
  • Files reviewed: 16/17 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 16, 2026 19:34
@bill-long

Copy link
Copy Markdown
Owner Author

Addressed both suppressed findings from review #5227362999 in 260b155:

  • The linked-image viewer now observes pathname, search, and hash. Browser tests cover each kind of navigation closing the viewer.
  • Direct external images no longer show Download, since displaying an image does not imply that its host permits a CORS download fetch. Open in browser remains available. Cached media and attachments retain Download, with browser assertions covering both cases.

The expanded tests also caught and fixed focus loss after Retry, which could prevent Escape from closing the viewer afterward.

All checks passed: lint, typecheck, 4,267 unit tests, 166 browser tests, production build, and a fresh local Codex review with no actionable findings. Requesting a new review of HEAD 260b155.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Preserve article preview alt text and prevent video previews from opening as images.

Review details

Suppressed comments (2)

src/features/room/urlPreviews/UrlPreviewCard.tsx:126

  • UrlPreviewData.image.alt is preserved by the cache and used on the card thumbnail, but this path drops it when constructing LinkedImage. For an article URL, isImageLink is false, so LinkedImageViewer supplies filename: null and ImageLightbox exposes the enlarged image as alt="Image", losing the only meaningful accessible description. Carry the OG alt text through the linked-image/lightbox descriptor while keeping filename for download naming.
		setLinkedImage({
			sourceUrl: props.url,
			fullUrl,
			...(img?.width !== undefined ? { width: img.width } : {}),
			...(img?.height !== undefined ? { height: img.height } : {}),

src/features/room/urlPreviews/UrlPreviewCard.tsx:137

  • The thumbnail handler treats isVideo() previews as external, but this condition only checks the URL suffix. A video preview whose source URL ends in .jpg/.png will therefore open the full-screen image from its title while the thumbnail opens the video in the browser. Exclude video previews from the in-app title path so both controls navigate consistently.
			event.altKey ||
			!isImageLink(props.url)
  • Files reviewed: 16/17 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 16, 2026 20:00
@bill-long

Copy link
Copy Markdown
Owner Author

Addressed both findings from review 5227526246 in 17e36c6:

  • Preserve preview alt text through both entry points and into the full-screen image.
  • Respect video metadata for message text and preview titles, including image-looking URLs. The sibling article case also keeps text/title navigation external.

The broader local review also caught and fixed thumbnail modified/middle-click navigation and article thumbnail download names. Preview thumbnails remain real links, and metadata survives filename selection so an article at a .jpg URL cannot name a cached PNG download.

The independent local reviewer ran lint, typecheck, 4,268 unit tests, 170 browser tests, and the production build successfully on the final state, then reported no remaining actionable findings. Browser/test startup restrictions were handled with approved command-specific retries in normal Codex execution mode. The dedicated local review was also completed before this push.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cached metadata-only previews can misclassify image-looking URLs; preserve their type metadata before applying image-link interception.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 17/18 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/features/room/urlPreviews/LinkedImageViewer.tsx Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 20:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

An unresolved moderate issue can render an article URL as an image when cached media is unavailable.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 18/19 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/lib/imageLink.ts Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 20:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Align the hero card DOM order with its visual order so keyboard focus follows the displayed sequence.

Review details

Suppressed comments (1)

src/features/room/urlPreviews/UrlPreviewCard.tsx:171

  • On hero cards, the title anchor is first in the DOM but is rendered after the thumbnail via order-2 (the image is order-1 below). Because these are now two separate focusable links, keyboard focus proceeds title → image while the visual order is image → title, which makes navigation inconsistent for keyboard users. Keep the DOM and visual order aligned, for example by making the image first in the DOM and applying explicit flex orders for compact cards.
				class={`min-w-0 flex-1 rounded no-underline hover:bg-surface-3 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-border-focus ${isHero() ? "order-2 w-full p-2" : "self-stretch content-center"}`}
  • Files reviewed: 18/19 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 16, 2026 20:39
@bill-long

Copy link
Copy Markdown
Owner Author

Addressed the suppressed focus-order finding from review 5227994518 in fe023d3. Hero previews now display title then image, matching DOM and keyboard order; compact cards retain their title-then-thumbnail layout. Browser regressions verify visual rectangles and Tab order for both layouts.

The local reviewer also caught a sibling shared-image failure case before push. Focus restoration now runs when the thumbnail is removed, covering a failure emitted by a different card sharing the same image. A two-card browser regression reproduced and verifies that fix.

Both local reviews completed with no remaining actionable findings. The independent reviewer ran lint, typecheck, 4,270 unit tests, 175 browser tests, and the production build successfully using command-specific approved retries.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Pending preview metadata can misclassify article or video URLs ending in .jpg before their metadata settles.

Review details

Suppressed comments (1)

src/features/room/urlPreviews/LinkedImageViewer.tsx:36

  • peekPreview() only reads the settled cache; while getOrFetchPreview() is still in flight it returns undefined (see previewCache.ts:146-168). This handler then treats an image-looking URL as a direct image before the pending og:type is known, so an article/video page ending in .jpg can open the HTML page in the viewer instead of retaining external navigation. Track/await the in-flight preview for this click (and only apply the suffix fallback after it settles) so the metadata override is deterministic.
			const metadata = peekPreview(anchor.href);
			if (!isImageLink(anchor.href, metadata?.type)) return;
  • Files reviewed: 18/19 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@bill-long

Copy link
Copy Markdown
Owner Author

For review 5228117852, retaining immediate click handling is deliberate; no code change for the suggested wait.

The classifier is explicitly a URL-suffix heuristic when no settled metadata is available. Known article/video metadata overrides it. Previews can be disabled, unavailable, or still loading, so waiting for an in-flight request would make identical links behave differently based on network timing without establishing a general guarantee that the source is an image. It also delays the user interaction and can lose browser popup activation if the eventual result needs external navigation.

The fallback is bounded and visible: a non-image response fails image loading in ImageLightbox, which presents an inline failure and retains the original source URL in Open in browser. It never navigates the app to the source HTML. The existing uncached-link browser regression verifies immediate opening and the external action; the load-failure regression verifies the failure/Retry path. Metadata-only, rejected cached media, and settled article/video overrides now have separate regressions.

This tradeoff matches the request for immediate in-app image viewing and the project's interaction-latency requirement. The latest commit fe023d3 passes all local and CI checks. Please assess the implementation with this intentional best-effort metadata behavior in mind.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved moderate review comments remain.

Review details

Suppressed comments (3)

src/features/room/urlPreviews/LinkedImageViewer.tsx:36

  • peekPreview is a settled-cache read, but UrlPreviewList starts getOrFetchPreview asynchronously. If a user clicks an image-looking URL before that request settles, metadata is undefined and this handler intercepts it as an image even when the response will be og:type: article or video.*, so the metadata override is not reliable. Please either await/reuse the in-flight preview before preventing navigation or fail open while metadata is unresolved, and cover the click-before-preview case.
			const metadata = peekPreview(anchor.href);
			if (!isImageLink(anchor.href, metadata?.type)) return;

src/features/room/urlPreviews/LinkedImageViewer.tsx:91

  • This fallback is global rather than tied to the link that opened the viewer. If a linked image opened from a thread, pinned panel, or another virtualized timeline is recycled while the lightbox is open, closing it can focus the first main-room timeline scroller instead of the originating context. Capture the opener's nearest scroll/focus container (with an appropriate fallback) when opening so focus does not jump out of the thread or panel.
			fallbackFocus={() =>
				document.querySelector<HTMLElement>('[data-testid="timeline-scroller"]')
			}

src/features/room/urlPreviews/UrlPreviewCard.tsx:183

  • This cleanup belongs to the image <Show> owner, so it also runs when the whole card is unmounted (for example during room navigation), not only when a shared image fails. If the thumbnail has focus then, it focuses titleLink just before both nodes are removed, so focus still ends on body instead of a valid fallback. Gate the restoration on the image having actually failed (and the title link still being connected).
					onCleanup(() => {
						if (document.activeElement === imageLink) titleLink?.focus();
  • Files reviewed: 18/19 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 16, 2026 21:01
@bill-long

Copy link
Copy Markdown
Owner Author

Addressed the two focus findings from review 5228222845 in 6ad643a. Both linked-image entry points now capture their opener's focusable ancestor chain. If the opener is recycled, modal close returns to the nearest connected ancestor, keeping a thread or panel as the focus context before falling back to the main timeline. Browser regressions cover recycled text and preview openers in a separate timeline. Thumbnail cleanup is now gated on an actual image failure and a connected title, so normal card unmounts do not move focus.

Both local reviews found no functional issues. Their formatting finding was corrected and lint rerun successfully. Validation: lint, typecheck, 4,270 unit tests, 177 browser tests, production build. The independent reviewer ran the test suites using approved command-specific retries.

The pending-metadata recommendation remains intentionally declined for the reasons in #688 (comment). URL classification is best-effort and immediate until metadata has settled; the viewer's load-failure surface retains Open in browser. The PR description now states that boundary explicitly. Please distinguish that documented behavior from a defect requiring asynchronous click handling.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Restore the play affordance for compact video previews.

Review details

Suppressed comments (1)

src/features/room/urlPreviews/UrlPreviewCard.tsx:225

  • Compact video previews no longer render the play affordance. The previous branch showed the overlay for every isVideo() card, but this condition now restricts it to hero cards, so small/portrait video thumbnails look like ordinary images even though their link is a video. Keep the overlay for compact cards as well.
							<Show when={isVideo() && isHero()}>
  • Files reviewed: 18/19 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 16, 2026 21:24
@bill-long

Copy link
Copy Markdown
Owner Author

Addressed the latest compact-video play-affordance finding in 5458e87 by restoring the original preview-card rendering, including its video overlay. The card now differs from main only by an anchor marker used by the image-click delegate.

The same commit deliberately narrows the design to direct HTTPS raster-image URLs and the existing viewer. It removes the split thumbnail/title links, article-thumbnail viewing, preview metadata/cache routing, and custom download-name behavior. Image detection now has one explicit URL-suffix rule, independent of pending or cached OpenGraph metadata. A URL ending in an image suffix that returns HTML uses the viewer's existing failure state and external link. Ordinary article/video URLs preserve their original external navigation.

The earlier context-focus fix remains: recycled openers return to a surviving focusable ancestor. The thumbnail cleanup path was removed with the split-link design. Existing attachment downloads remain unchanged; linked images provide Open in browser.

Dedicated local review and a separate execution-capable reviewer completed. Lint, typecheck, 4,258 unit tests, 168 browser tests, and production build passed. Browser tests now intercept fixture media deterministically; a separate live check loaded the reported EVE JPG and verified viewport fit, external URL, and Escape.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Article or video previews with image-like URLs can incorrectly open in the image viewer instead of retaining external navigation.

Review details

Suppressed comments (1)

src/features/room/urlPreviews/UrlPreviewCard.tsx:137

  • Every preview card gets this marker, but LinkedImageViewer only checks anchor.href with isImageLink and never sees props.data.type. An og:type: article or video.* preview whose source URL happens to end in .jpg, .png, etc. will therefore open the page in the image viewer (and show the image-load error) instead of retaining its external navigation. Preserve the preview type in the marker/handler and exclude those preview types from in-app routing.
			data-link-preview
  • Files reviewed: 15/16 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@bill-long

Copy link
Copy Markdown
Owner Author

Regarding review 5228580914 on 5458e87: no code change is planned for this finding because URL-suffix routing is the deliberate final scope of the simplified design, documented in the PR description and preceding review reply. Both message links and preview cards use the same isImageLink rule, regardless of whether preview metadata has arrived. Passing og:type from the card would make the same URL behave differently in message text versus its card, and depending on preview availability.

Evidence: src/lib/imageLink.ts defines the HTTPS raster pathname rule in one place; LinkedImageViewer.tsx uses the original href and does not read preview metadata. Browser coverage verifies both entry points, successful loading, the image-error state with the original external URL retained, and ordinary article/video navigation. The actual reported EVE JPG loads and fits successfully in a separate live check. A misleading image-suffixed HTML URL intentionally shows the existing image-error surface with Open in browser available. This is a known, bounded limitation rather than a promise to infer response MIME type from OpenGraph data.

The earlier compact-video affordance issue is fixed by restoring the original single-anchor card layout. All CI checks, including browser tests, passed on 5458e87. The latest overall review verdict is still Needs a closer look; this reply records the design rationale rather than claiming bot approval.

@bill-long
bill-long merged commit d4e3f9e into main Sep 17, 2026
4 checks passed
@bill-long
bill-long deleted the codex/linked-image-viewer branch September 17, 2026 02:03
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.

2 participants