Skip to content

feat(ios): add long-press menus for links - #923

Merged
hryhoriiK97 merged 10 commits into
feat/link-pill-icon-tintfrom
feat/link-context-menu-items
Oct 7, 2026
Merged

hryhoriiK97 merged 10 commits into
feat/link-pill-icon-tintfrom
feat/link-context-menu-items

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

What/Why?

Adds linkContextMenuItems: a native menu when a link is long-pressed, on iOS 17 and later. Proposed in #864.

This replaces #865. The feature and its native approach are @juliusmarminge's; here it is reworked to fit the link API the pill work established and stacked on feat/link-pill-icon-tint (#921), so the diff shows only the menus.

What changed compared with #865:

  • Keyed by URL pattern, not by exact URL. Keys are regex patterns matched like markdownStyle.linkVariants, so one entry covers every link of a kind and the app does not have to list each link of each message.
  • The value is the list of items, and the prop is named linkContextMenuItems, next to contextMenuItems. The menu is titled with the link's text, or its pill label, so no title has to be configured.
  • Native is told only when the menus change. feat: add per-link native context menus on iOS #865 rebuilt every menu on each prop update, including every streaming update.
  • Table menus lift only the link, not the whole table.
  • A pill is lifted as itself. UIKit lifts a pressed text item on a padded box tinted from the pill's colors, which hides the pill's look. A pill with a menu is lifted as an image of itself instead, in text and in tables alike. Plain links keep the system highlight.

API

<EnrichedMarkdownText
  markdown="Open [README.md](https://example.com/files/README.md) or ask [@gregory](user:gregory)."
  linkContextMenuItems={{
    '^https://example\\.com/files/': [
      { text: 'Copy path', icon: 'doc.on.doc', onPress: ({ url }) => copyPath(url) },
      { text: 'Delete', icon: 'trash', destructive: true, onPress: ({ url }) => deleteFile(url) },
    ],
    '^user:': [{ text: 'Mention', icon: 'at', onPress: ({ url }) => mention(url) }],
  }}
  onLinkLongPress={({ url }) => showFallbackMenu(url)}
/>
  • The longest pattern that matches a link's URL supplies its menu. A pattern anchored to one URL gives that link its own menu.
  • A link with a menu shows it instead of onLinkLongPress and the system link preview. A link that matches no pattern, or whose pattern has no visible items, keeps its existing long-press behavior.
  • Items have text, onPress({ url }) and optional icon (SF Symbol), visible, disabled, destructive. text identifies the item within its pattern.
  • Android, macOS and iOS below 17 ignore the prop; onLinkLongPress is the hook there.

Implementation

  • JS: normalizeLinkPatternEntries is the ordering rule linkVariants already used (longest pattern first, invalid regexes dropped), now shared by both props. The menus are flattened without callbacks and compared structurally, so a re-render that only changes callbacks sends nothing to native. Native reports a press with the URL and the pattern that supplied the menu, and JS looks the item up in the current value; a press on an item that is no longer offered is ignored.
  • iOS: ENRMLinkContextMenus holds typed entries, compiles each pattern once and remembers which entry a URL matched. One function, ENRMLinkMenuConfigurationForTextItem, answers the iOS 17 text-item menu callback for the three text view delegates (both components and the blockquote container). The table grid reports the link under a point with its title and frame, which the table container uses for a targeted preview.
  • iOS, lifting a pill: ENRMMarkdownTextView presents the menu of a pill itself, through its own UIContextMenuInteraction, and reads the menus from its delegate (ENRMLinkContextMenuSource). The text-item callback gives UIKit no menu for such a pill; with one, UIKit builds its own box around the pill before ours lifts. ENRMLinkMenuLift, shared with the table, lays an image of the link over its view for UIKit to lift: a preview is drawn only from a view that is on screen, and UIKit hides that view while the menu is open, so lifting through a visible path hid the whole table. The pill in the text draws nothing while its copy is lifted, and new text takes the copy down.
  • iOS, tables: the table's own menu and a link's menu are told apart by the stored link frame. UIKit replaces a nil configuration identifier with one of its own, so the earlier identifier check sent the table's menu down the link path.
  • Android: the prop is accepted and ignored.
  • linkContextMenuItems is added to the web list of native-only props.

One thing to know when reviewing: implementing the iOS 17 text-item menu callback also puts links without a menu on that path. They behave as before: the system menu with its preview, or onLinkLongPress when previews are off. Both cases were checked on a device.

Testing

CI does not run on this PR, because the workflow only triggers for PRs that target main. Run locally on this head:

  • Jest: 76 pass, including the tests from feat: add per-link native context menus on iOS #865 updated to the new shape (flattening, pattern order, change detection, dispatch guards, stale presses after an update).
  • Android: builds, Kotlin lint clean.
  • iOS: builds. No native tests were added for the menus.
  • On an iOS simulator (iPhone 17, iOS 26.5), with a local demo screen that is not part of this PR:
    • a pill, a pill with a per-link label, and a plain two-line link each show the menu, titled with what the link shows;
    • an item press reaches JS with the link's URL; a disabled item is greyed out, a destructive one is red;
    • a pill inside a blockquote and a pill inside a table show the menu, and in the table only the pill is lifted;
    • long-pressing a table outside a link still lifts the whole table with its own menu, also right after a link menu;
    • a link with no menu fires onLinkLongPress exactly once per long press.
    • a link with no menu and no onLinkLongPress still shows the system link preview and menu.

The pill lift (the last two commits) was checked by hand by @hryhoriiK97 on iOS 26 simulators, on a dark local demo screen: pills in a paragraph, a list, a blockquote and a table lift as themselves with no box before the menu; long-pressing plain text still starts a selection; a plain link with a menu still shows it.

Not covered: iOS below 17, iOS 17 and 18 for the pill lift, VoiceOver, the pill lift in light appearance, and the Storybook story on a device. On macOS the files compiled before the last two commits (that build fails for two errors that already exist on the base branch); it was not rebuilt after them, and their new code is iOS-only behind TARGET_OS_OSX guards. The Maestro flow from #865 is not included: it was written for the old shape and had not been run.

PR Checklist

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added (if applicable)

🤖 Generated with Claude Code

`linkContextMenuItems` gives links a native menu on long press (iOS 17+). It is
keyed by URL pattern and matched like `linkVariants`, so one entry covers every
link of a kind:

  linkContextMenuItems={{
    '^https://example\\.com/files/': [
      { text: 'Copy path', icon: 'doc.on.doc', onPress: ({ url }) => copy(url) },
    ],
  }}

A link with a menu shows it instead of `onLinkLongPress` and the system
preview; other links keep their long-press behavior. The menu is titled with
the link's text or its pill label. It works in text, blockquotes and table
cells, where it lifts only the link.

Native hears about the menus only when what they show changes, not when
callbacks do. Android accepts the prop and ignores it.

Based on #865, reworked from menus keyed by exact URL to item lists keyed by
pattern.

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hryhoriiK97 hryhoriiK97 mentioned this pull request Oct 4, 2026
4 of 6 tasks
hryhoriiK97 and others added 7 commits October 4, 2026 19:08
Without a preview UIKit greys the pressed text out, which hides a pill's own
look. A pill with a menu is now shown as its own image on a small platter in
the background color, as it already was in table cells.

Plain links keep the system highlight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three problems with the link menu in tables:

- UIKit hides the view a preview is made of while the menu is open. The
  link was lifted through a visible path into the grid, so the whole
  table vanished behind the menu.
- A preview is drawn only from a view that is on screen, so a detached
  view stays blank until the menu appears. The link is now lifted as an
  image of itself laid over the grid, removed when the menu ends.
- UIKit replaces a nil identifier with one of its own, so the table's
  own menu was taken for a link menu: it lifted a sliver of the grid, or
  the last link pressed. The stored link frame now tells them apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pill with a menu in a paragraph, list or blockquote was lifted by
UIKit as a text item: a padded box in the pill's colour first, then a
jump to a platter of our own once the menu opened.

The text view now presents that menu itself and gives UIKit none for
such a pill, so UIKit builds no box. The pill is lifted as an image of
itself laid over the text, and draws nothing in the text meanwhile, as
it would show through its lifted copy. New text takes the image down.

The table lifts its links the same way, so both now share
ENRMLinkMenuLift. The menu protocols live with the menu utilities, which
no longer need to know the text view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	packages/react-native-enriched-markdown/ios/attachments/ENRMLinkPillAttachment.h
@eszlamczyk
eszlamczyk added this pull request to stack #946 October 7, 2026 13:35
@hryhoriiK97
hryhoriiK97 merged commit 7afb1b0 into main Oct 7, 2026
16 of 30 checks passed
@hryhoriiK97
hryhoriiK97 deleted the feat/link-context-menu-items branch October 7, 2026 18:11
@hryhoriiK97 hryhoriiK97 mentioned this pull request Oct 7, 2026
3 of 4 tasks
eszlamczyk added a commit that referenced this pull request Oct 9, 2026
* refactor(ios): split the style structs into one file each (#878)

MarkdownStyleConfig.swift held every resolved style record and sat at
561 lines against the 600-line swiftlint ceiling, with ImageStyle.swift
as the lone exception. Move the other ten (ElementStyle,
BaselineShiftStyle, ThematicBreakStyle, CodeBlockStyle, AdmonitionStyle,
BlockquoteStyle, ListStyle, TaskListStyle, TableStyle + TableAlignment,
SpoilerStyle) into Theme/Styles/, one per file, and ImageStyle.swift in
with them. Pure move: no declaration changes.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(ios): align the public API with SwiftUI conventions (#879)

* refactor(ios): align the public API with SwiftUI conventions

Applies the SwiftUI API audit of the package. Every 0.1 name stays as a
deprecated shim under Sources/EnrichedMarkdown/Deprecated/ (renamed: for
pure renames, so Xcode offers fix-its); removal waits for the next major.

- `Md4cFlags` -> `MarkdownParsingOptions`, `flags:` -> `options:` on
  EnrichedMarkdownText, MarkdownRenderer.render/renderLaTeX, and Parser
- link taps go through the `openURL` environment action; a legacy
  `onLinkPress` handler still wins when installed, and `onLinkLongPress`
  becomes `onMarkdownLinkLongPress`. openURL alone leaves a long-press to
  the system menu instead of firing the tap
- `.font(_:)` resolves every text-style spelling, including design and
  weight, from a hashed table; any other Font logs a runtime warning
  before the `.body` fallback. fontSize/fontFamily stay as the explicit
  forms for point sizes and custom faces
- `cornerRadius` (BlockImage, CodeBlock, Table), `checkboxCornerRadius`,
  `marginLeading`, `multilineTextAlignment`, `foregroundStyle` on
  ThematicBreak and Spoiler, `Table.alignment(_: HorizontalAlignment)`,
  `onTaskListItemToggle` with `TaskListItemToggle(index:isChecked:text:)`,
  `MarkdownSelectionMenu(copyImageURL:)`, `markdownTextSelection(.enabled)`
- the style records rename the same fields; each deprecated memberwise
  init requires its old label so calls without it pick the live init
- CodeBlock conforms to MarkdownThemeElement (gains fontDesign, bold,
  alignment; the renderer honors it); Blockquote, List, and Table apply
  text styles through one package TextStyleRecord protocol
- rememberMarkdownTheme is deprecated: it never did anything SwiftUI's
  body re-evaluation does not

README examples use the new names and end with a migration table; the
example app is migrated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(ios): group modifiers whose values belong together

Grouped where SwiftUI bundles the same tuple in one modifier, or where
the values only apply under one overlay; independent values stay flat.
Every 0.1 form stays as a deprecated shim under Deprecated/.

- `.border(_:width:)` on CodeBlock, Blockquote, and Table through a new
  BorderThemeElement protocol, as SwiftUI's border(_:width:). A nil width
  keeps the width a lower theme layer set, so a dark-mode layer can
  recolor a border without restating it. TaskList keeps borderColor: it
  has no width to pair with
- `Table().cellPadding(horizontal:vertical:)`, either side optional
- spoiler tuning moves from the theme onto the overlay choice:
  `.markdownSpoilerOverlay(.particles(density:speed:))` and
  `.markdownSpoilerOverlay(.solid(cornerRadius:))`, as static functions
  beside the bare `.particles` / `.solid`. The providers keep their
  parameters in stored properties, so Equatable synthesis rebuilds the
  overlays when they change. The views take the provider's value, then
  the theme's (written only by the deprecated modifiers), then the
  built-in default. `Spoiler()` keeps foregroundStyle and background
- the stored field and SpoilerStyle field become solidCornerRadius

Kept flat on purpose: checkbox size and corner radius, superscript and
subscript scales, list bullet and marker values.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(ios): close the remaining gaps to SwiftUI conventions

A second pass over the public surface after the audit renames.

- `.foregroundStyle(.secondary)` and friends now compile: every Color
  overload is @_disfavoredOverload, so a bare member name resolves to
  the semantic color while an explicit Color still takes the Color
  path. The default theme's private typealias workaround is gone
- `MarkdownStyleConfig` -> `MarkdownStyleConfiguration` (deprecated
  typealias); the `config:` label and local names stay short
- `fontWeight(_:)` and `italic()` join `bold()`, and all four, plus
  `fontDesign`, layer over the font a lower theme set. `Heading(1).bold()`
  on its own was a silent no-op before, and switching to the
  monospaced design reset a semibold face to regular
- `font(size:weight:design:)` and `font(custom:size:)` replace
  `fontSize(_:weight:)` and `fontFamily(_:size:)`, shaped like their
  `Font` counterparts
- Table: `headerFont(_:)` takes a text style, `headerFont(custom:size:)`
  replaces `headerFontFamily`, `headerForegroundStyle` replaces
  `headerTextColor`
- `.tertiary` joins the semantic colors; `MathBlock().font(size:)`
  replaces `fontSize`

`isItalic` is a new MarkdownThemeElement requirement with a
storage-less default, so outside conformers keep compiling. Every 0.1
name stays as a deprecated shim.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ios): clear the package's compiler warnings

A clean build of the example app surfaced five warnings from the
package, all older than this branch:

- BlockquoteRenderer computed a `depth` it never read
- EmphasisRenderer and StrongRenderer passed an optional font where
  `Any` is expected, boxing the optional; both now unwrap it first
- MarkdownTheme and MarkdownThemeGroup are Sendable but held
  `any MarkdownThemeContent`, an error in Swift 6. The protocol now
  requires Sendable; every element is a struct of Sendable values

The three md4c.c warnings are in the shared core and are left alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(ios): remove dead code

A reference sweep over the package found nothing private that is dead
and no theme field that no renderer reads. Six declarations had no
callers outside their own tests, or none at all:

- the empty `EnrichedMarkdown` namespace enum. Nothing used it, and a
  type named after its module takes over qualified lookup, so
  `EnrichedMarkdown.List()` failed with "type has no member". With it
  gone, module qualification disambiguates our List, Link, and Table
  from SwiftUI's as documented
- `ThemeResolver.font(from:traitCollection:)`, a wrapper with no callers
- `RenderContext.reset()`, no callers
- `RenderContext.spacerStyle(height:spacing:)` and its template, a
  test-only duplicate of ParagraphStyleHelpers.spacerParagraphStyle
- `ParagraphStyleHelpers.applyHeadIndent` and `applyTextLists`,
  test-only leftovers of an earlier list layout

Their three tests go with them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ios): address review of the SwiftUI conventions pass

- CodeBlock and Code no longer store `.monospaced` in their initializer.
  Since weight and design now layer over the lower theme's font, that
  default made `CodeBlock().foregroundStyle(.red)` reset a serif code
  font. A `defaultFontDesign` hook applies it only to the element's own
  font spec, so `CodeBlock().font(size: 14)` stays monospaced
- `italic(false)` removes an inherited italic: the family's upright face
  when it has one, else the descriptor without the trait
- the font-resolution warning and doc comment name the live modifiers
- `border(width:)` resizes a border a lower layer colored, so the
  deprecated `borderWidth` has a non-deprecated replacement
- tests that a table-cell tap and VoiceOver activation with only
  `openURL` set open through it; `TableAttachmentView.openLink` is the
  seam the tap handler calls

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(ios): give custom spoiler overlays the text's baseline and segment order (#894)

* feat(ios): give custom spoiler overlays the text's baseline and segment order

A custom overlay that shows the text through (blur, pixelation) drew
`concealedText` at its own origin, and the slice carries inline styling
only. With a theme line height taller than the font the glyphs landed a
few points above the real ones, so the text jumped when the overlay was
removed at the end of the reveal; the README's blur sample had the
same drift.

`SpoilerOverlayView` now carries `baseline`, taken from the TextKit 2
segment the text view laid out, and `concealedTextImage()` draws the
slice with CoreText at that baseline, applying per-run baseline
offsets, so nothing shifts on removal. It also carries `segmentIndex`
and `segmentCount` so a wrapped spoiler can reveal line by line.

The README documents the new API, switches the blur sample to it, and
notes that an opaque overlay only blends in when the theme's spoiler
background matches what is actually behind the text view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ios): give a spoiler overlay its segment order before adding it

A new overlay received segmentIndex and segmentCount only after
addSubview, so a subclass reading them from didMoveToSuperview or
didMoveToWindow saw the defaults. Set every property before the view
joins the text view, and keep refreshing the order on reused overlays,
whose place changes when the line above them rewraps. Both properties
now name their type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ios): resolve spoiler overlay colors for the view's own traits

concealedTextImage() handed CoreText color.cgColor, which resolves a
dynamic color against UITraitCollection.current. Inside layoutSubviews
that is the overlay's trait collection, but a subclass rendering from
animateReveal could pick up whatever was current last and draw the
slice in the other appearance. Resolve against the view's
traitCollection instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Maksymilian Galas <max@infinitypi.co>

* fix(web): prevent native props from reaching the DOM (#818)

Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>

* perf(ios): read textStorage instead of copying attributedText per a11y element (#898)

UITextView.attributedText returns a fresh copy of the whole text on
every call. blockquoteAnnouncementForRange: read it three times per
element and frameForRange: once more, so building the accessibility
elements cost O(elements x document length). textStorage is the same
text without the copy.

On the example app (Release, iPhone 17 Pro simulator), the first
accessibilityElements call on a 1,600-section document (11,202
elements) drops from 46.6 s to 1.7 s. Element labels, traits, values,
hints, frames and rotor items are unchanged.

Fixes #897

Co-authored-by: Matt Voska <voska@users.noreply.github.com>

* perf(android): hand the rendered buffer to the text view uncopied in React Native (#889)

* fix(ios): yoga measurement handles incorrectly with number of lines set (#909)

* perf(ios): render and draw long documents faster and in less memory (#891)

* perf(ios): cache font trait resolution

Resolving bold or italic read font descriptors and walked the family's
faces on every strong or emphasis run, about 80 µs each; a document of
500 bold and italic paragraphs spent two thirds of its render there.
Results are remembered per font behind a lock, and that document now
renders in a quarter of the time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(ios): skip the baseline-shift pass on documents without one

applyShifts walked every attribute run of the document twice, even when
nothing was superscript or subscript: 50 to 75 ms on long documents,
on every render. The render context notes when either renders and the
pass returns early otherwise, for the document and for table cells.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(ios): read the document's last character in place

`output.string.hasSuffix("\n")` bridges the whole backing store to a
Swift String, 42 µs per 500 KB, and every block renderer asked it, so a
long list paid a copy of the document per item. The newline helpers
read the last character through mutableString, and the spacer check in
ListItemRenderer scans its range against CharacterSet.newlines instead
of copying it out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(ios): draw the image placeholder once

Every image attachment rendered its own 1×1 placeholder bitmap on every
render; one static image serves them all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(ios): skip renders superseded before they start

Under streaming the serial render queue rendered every intermediate
document in full before it reached the latest one. A render now checks
the newest schedule id before it starts, so a schedule superseded while
queued is dropped instead of rendered and discarded. The id lives in an
OSAllocatedUnfairLock because the queue reads it off the main thread.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(ios): draw block chrome from one fragment walk, tiled to the viewport

The decoration drawers asked TextKit 2 for enumerateTextSegments once
per paragraph. That call costs time proportional to the range's position
in the document, so a draw was quadratic: 4 s for a 2000-item list and
3.7 s for a 130 KB document, on the main thread, on every layout pass
and every SwiftUI update. ParagraphLayoutWalker now reads every
paragraph's lines from one enumeration of the layout fragments, which
hand out the same bounds and baselines (verified pixel-identical against
the old output), bounded to the drawn rect and started by bisecting
character offsets, since every point- or rect-based TextKit lookup also
walks from the document start.

The two decoration views were bitmaps the height of the document: +304 MB
and 550 ms of display for a 28k-pt document. They now cover a tile around
the enclosing scroll view's visible region and move when the region
leaves it; outside a scroll view, or for a document shorter than a tile,
they still cover the whole document. A tile that cuts a code block
completes the block through its neighbours so the corners stay put.

styleConfig repaints the chrome only on a real change; updateUIView
assigns it on every pass. The marker drawer previously read only the
first directional run of a mixed-direction first line, which could put
an RTL marker mid-line; it now reads the whole line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(ios): add opt-in performance benchmarks

Skipped unless TEST_RUNNER_ENRICHED_MARKDOWN_BENCHMARKS=1 is passed to
xcodebuild test. Measures rendering, first layout and the decoration
display of a windowed text view through XCTest's measure, using only API
that exists on either side of the perf changes, so the same file runs
against any checkout for a before/after comparison.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(ios): add a before/after benchmark runner

scripts/benchmark.mjs runs the opt-in PerformanceBenchmarks on the
current checkout and, with --base <ref>, on that ref in a throwaway
worktree with the RaTeX vendor files restored, then prints both with the
head/base ratio; --only narrows to one test, --markdown appends the
table to a file, --fail-above turns a ratio into an exit code. Wired as
`bench:ios-native` and described in the README's Development section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(ios): compare benchmarks against the base branch on pull requests

Every pull request that touches the iOS package now runs the opt-in
benchmarks on its head and on the base branch tip, back to back on the
same runner, and fails when any benchmark is more than twice as slow.
Absolute numbers on a shared runner are noise, but a same-runner ratio
still catches order-of-magnitude regressions like the ones the perf
audit found. The job is not required by ci-success until it has proven
quiet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(ios): compare benchmark medians and mark each ratio

XCTest's average is at the mercy of one stalled iteration: on a shared
runner the bold-paragraph render came out 1.28x slower with a ±73%
deviation, four normal iterations and one that paused for seconds, and
locally the first iteration of every measurement is a cold outlier. The
runner script now parses the per-iteration values and compares medians,
which put that row back at 0.32x while a real 2.5x regression still
trips the gate.

The report marks each ratio as faster (below 0.8x), within noise,
slower (above 1.25x) or over the failure threshold, with a verdict line
and a legend in the Markdown and ANSI colors on a terminal. The table
lists base before head, build products live under ~/Library/Caches
rather than the temp folder macOS prunes piecemeal, a failed build gets
one clean retry, and the report functions are importable for testing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(ios): comment benchmark results on the pull request

The benchmark job writes its table to a file and a final step, run even
when the 2x gate fails, appends it to the job summary and posts it on
the pull request with gh, editing the previous comment in place. The
job takes pull-requests: write for that; a fork's read-only token only
logs a warning. It also waits for the simulator to finish booting before
the first measured run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(ios): run the benchmarks on demand instead of on every pull request

A dedicated workflow replaces the ios-benchmarks job in ci.yml. It runs
when a pull request gets the `benchmark: ios` label (and on later pushes
while the label stays), when someone with write access comments
`/benchmark ios` on it, or from the Actions tab with a pull request
number. Each way resolves the pull request's base and head, runs the
same comparison, and posts the same comment. Comment- and
dispatch-triggered runs use the workflow file on main, so those two
paths go live once this lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(ios): address review of the on-demand benchmark workflow

Only benchmark-triggering events may cancel a run in progress: any
comment on the pull request creates a run in the concurrency group and
was cancelling the benchmark before its own job got skipped. The
resolve step captures the pull request's refs into a variable and
rejects empty ones, where a failing gh inside an echo passed unnoticed
and left the base empty. The job times out after an hour, the reaction
to a `/benchmark ios` comment comes before any step that can fail, the
results comment uses --create-if-none, and the runner script prunes a
worktree registration an interrupted run left behind before adding one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ios): address review of the decoration walk and tiling

The decorator added the text view's content offset to the tile, but a
subview's frame is already in the content's coordinates, so any offset
moved every marker, bar and background; the tile is used as is, with
the container inset accounted for. A code block cut by a tile got its
rect by walking neighbours with insert(at: 0), quadratic in the lines
above the tile; its range now comes from the attribute run and its end
paragraphs from two constant-time lookups. The walker lays out any
fragment TextKit has not laid out before reading it, bisection probes
included, since such a fragment reports a zero frame. The text view
looks for its enclosing scroll view on every hierarchy report and on
layout, as an ancestor moving into a scroll view reaches it only that
way. The benchmark runner rejects a non-numeric threshold.

Also: one shared code-block range lookup for the drawer and the
accessibility builder, which now bounds its list-depth scan to the item
instead of walking every sibling, and an accessibility benchmark.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(ios): post every benchmark run as a new comment

The publish step edited the bot's last comment in place, which kept the
table at its old spot in the thread and sent no notification: a run on
a later push refreshed a comment from days earlier and nobody saw it.
Every run now adds its own comment at the bottom of the thread, and the
table's header names the head commit it measured (with a -dirty suffix
on a local run with uncommitted changes) so several tables on one pull
request stay apart. Comment- and dispatch-triggered runs read the
workflow on main, so those two paths change once this lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* chore: release v1.1.0 (#910)

* fix(input): ensure taps on rich text input don't trigger parent onPress (#884)


Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(ios): move spoiler overlays on layout and pass the tap point to reveals (#895)

A re-layout used to replace every overlay whose frame changed, so a
custom effect lost its state whenever content above it changed height,
such as an image finishing loading: particles restarted, anything
mid-motion snapped. Overlays are now keyed by spoiler range and segment
ordinal. A segment that keeps its range, size and baseline moves its
view, and one that changed is replaced.

Within one text, the same range and size means the same glyphs, so the
content is only compared after the text view replaces its text, against
the slice the overlay was built from. That keeps layout passes and
re-renders at the old cost, and fixes new text laid out at the same
place and size keeping the old overlay and its stale text.

`SpoilerOverlayView.revealPoint` is set from the tap before
`animateReveal` runs, in the view's coordinates, so an effect can wipe or
ripple out from the reader's finger. It is nil for a programmatic reveal,
and a second tap during the reveal leaves it unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ios): give each list item's marker array its own hash (#913)

Each list item stores its markers as an NSArray attribute value. NSArray's
-hash is its count, so every one-marker array hashes to 1, while the
descriptors inside compare by identity, so no two of those arrays are equal.
UIFoundation uniques attribute dictionaries in a hash table, so adding item
N's marker probes past every earlier item's dictionary: rendering is
quadratic in the number of list items, and memory grows with it.

Store the markers in an NSArray subclass whose -hash is the hash of the
item's own (last) marker. It is still an NSArray, so ListMarkerDrawer and
TaskListTapUtils read it unchanged, and arrays that are equal still hash
equal.

Co-authored-by: Matt Voska <voska@users.noreply.github.com>

* fix(ios): pass accessibility labels into GFM blockquote children (#919)

A list inside a blockquote crashed any accessibility walk with
flavor="github": the quote's child views never received
accessibilityLabels, so formatListAnnouncement returned nil and it was
inserted into an NSMutableArray.

Give ENRMBlockquoteContainerView an accessibilityLabels property, set it
from the root view, hand it to text/table/math/nested-quote children and
push changes to existing children. Also nil-check the list announcement
like the blockquote announcement next to it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ios): account for baseline offset when drawing underline and strikethrough (#916)

TextKit 1 does not incorporate NSBaselineOffsetAttributeName when drawing
underline and strikethrough decorations. When lineHeight is larger than
the fonts natural line height, applyBaselineOffset shifts glyphs upward
to center them within the line fragment, but decorations remain anchored
to the original unshifted baseline. With sufficient shift the underline
intersects descenders and iOS breaks the stroke around glyph shapes,
producing a dotted/fragmented appearance.

Override drawUnderlineForGlyphRange: and drawStrikethroughForGlyphRange:
in TextViewLayoutManager to read NSBaselineOffsetAttributeName from the
text storage and add it to the baselineOffset parameter passed to super.
This keeps decorations aligned with the shifted glyphs.

The fix is paint-only: no layout, measurement, or view-height changes.
When the attribute is absent (shift is 0), the draw methods pass the
original arguments through unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(android): add highlight support to enriched-markdown-android (#844)

* fix(ios): make attributed text explicitly set NSOriginalFont (#929)

* fix(ios): make attributed text explicitly set NSOriginalFont

* fix: address cr comments

* chore: release v1.1.1 (#934)

* docs: add the Docusaurus documentation site(#713)

* feat(docs): add docusaurus documentation site
* docs: add agent guide for the docs site
* feat(docs): add topbar banner, add gtm integration (#540)
* feat: updated documentation
* feat: update platform badge
* feat: introduced file structure; wrote some docs; fixed configs
* feat: updated first documentation page
* docs: finished first page of documentation
* docs: update configs
* docs: polished speech
* docs: fundamentals done
* docs: checkpoint WIP before merging main
* docs: correct underline (Android/RN) and web autolink cells
* docs: add LivePreview editable playground and wire first-project examples
* feat: finished react native basics section
* docs: react native api reference for two main components (#722)
* docs: finished styles docs (#733)
* docs: finished v1 docs (#735)
* docs: finished element structure for rn
* feat: rich text formatting
* docs: finished misc, last cleanups
* docs: up to date docs (#737)
* docs: up to date documentation
* feat: update ci
* docs: polishing documentation 1
* docs: keep both InteractiveExample panes mounted for SSR
* feat: harden docs ci (#838)
* docs: post review cleanup 1 (#840)
* docs: moved flavour page to react native
* docs: replace ratex link with their page
* docs: moved rn-related sections to rn sidebar
* docs: add use memo to all docs styles
* fix: lefthook link nowarn ignored files flag
* docs: contrubiting section
* docs: roadmap page
* docs: known limitations page
* docs: moved accessibility and rtl to separate section
* docs: added toc dots
* feat: update the web information to drop react-native-web from it
* fix: fix favicon on dev serwer
* fix: finished rn docs
* docs: android native docs v1 (#845)
* docs: update android api reference with new copose names (#876)
* docs: ios documentation v1 (#880)
* docs: update ios api reference to match the new api (#881)
* docs: native documentation polished (#882)
* docs: ios documentation polished
* docs: polished android docs
* docs: fixed ci; fixed routing
* docs: polish documentation according to last review (#914)
* fix: post merge update
* feat: polished documentation according to last review; hid ios and android docs
* docs: document the input press props and bump to 1.1.1
* docs(android): the highlight renderer landed
* docs: drop static/CNAME, correct the deploy notes
* docs: correct the press handler type to unknown

---------

Co-authored-by: Szymon Halski <halskiszymon@gmail.com>
Co-authored-by: Sullyvahnn-v2 <artur.wojnar@samorzad.agh.edu.pl>
Co-authored-by: Artur Wojnar <arturwojnar@Arturs-MacBook-Pro.local>

* fix(ios): classify writing direction beyond the BMP with CoreText (#907)

* fix(ios): classify writing direction beyond the BMP with CoreText

The first-strong resolver decided from a table of three BMP blocks, so a
letter beyond the BMP (Adlam, Old Hungarian) read as left-to-right and a
paragraph forced right-to-left with U+200F stayed neutral. The table keeps
deciding for the BMP, where it is exact; a letter beyond it is classified
by CoreText, and U+200E, U+200F and U+061C count as strong marks.

Leaving paragraphs .natural for TextKit to resolve, as proposed in #874,
does not work: TextKit right-aligns such a paragraph but keeps its indents
on the app's side, so the marker column ends up on the wrong side. A test
pins that down.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(ios): classify letters beyond the BMP from the range table

Unicode reserves U+10800-10FFF and U+1E800-1EFFF for right-to-left
scripts, and the two ranges are exactly the lead surrogates D802-D803 and
D83A-D83B. Two more entries in the table therefore classify a letter
beyond the BMP without laying out a CoreText line: the answer is the same
for every letter, and a paragraph that starts with one resolves in about
1 microsecond instead of 14 on the simulator. A combining mark from those
ranges that comes before any letter now resolves right-to-left too.

The tests gain a script under each of the four lead surrogates, the
left-to-right scripts next to the ranges and an emoji with a variation
selector. Comments are cut down to what the code cannot say, which drops
the two that review questioned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* perf(android): position inline code backgrounds from the drawing layout (#899)

* refactor(android): split compose style patches into one file per element (#900)

* ci(android): compare the display benchmark against main on demand (#908)

* feat(android): let apps supply custom spoiler overlays (#903)

* feat(android): add LaTeX math rendering as an optional plugin (#873)

* feat: add native link pills (#920)

* feat: add opt-in native link pill presentation (#862)

* feat: allow link variants to override font family

* fix: cleanup; docs: storybook

* feat: add opt-in native link variant pills

* fix: render iOS pill attachment only at its first source character

* fix: track pill glyph continuations without recursive layout queries

* perf: skip iOS pill glyph copies and range scans for ordinary text

* fix: address native link pill review findings

* refactor(link-pill): render pills as a native attachment and add per-link content

Rework the link pill presentation on top of the original implementation.

iOS: a pill link is now a single attachment character laid out and drawn by
TextKit, like images and inline math, instead of hidden link characters
substituted through a custom text storage. The link's own text is kept on the
attachment and put back wherever text leaves the view: copy, Markdown/HTML/RTF
export, accessibility, and the system selection actions.

Android: pills prepare their width per layout thread through a shared helper,
are set directly while rendering, and are redrawn through a span change when an
icon arrives or a spoiler over them is revealed.

Both platforms:
- `linkPillContent` prop: per-link label and icon keyed by exact URL
- `pill` is a nested object on the native link variant
- remote icons through the existing image pipeline, with bounded caches and a
  retry window for failed sources
- links holding an image, inline math, a hard line break or a spoiler stay
  ordinary links; a pill inside an unrevealed spoiler is hidden
- measurement caches account for link variants and pill content

Also makes the shared Android image downloader report a request OkHttp rejects
as a failed download instead of throwing into the render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(storybook): add link pill story

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(ios): run the link pill tests in the example test target

Add the pill attachment tests and the library header paths they import to
EnrichedMarkdownExampleTests, and let the target inherit only the pods' search
paths: linking the pods into the test bundle as well loaded every library class
twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: document link pill content and behavior

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(example): match the Podfile checksum to the test target change

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: keep Android unit tests out of the npm package

The android folder is published whole, so android/src/test shipped with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>
Co-authored-by: Ernest Szlamczyk <127619251+eszlamczyk@users.noreply.github.com>
Co-authored-by: Gregory Moskaliuk <mosckalyuck@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ios): keep list markers on the baseline when an item starts with an attachment

TextKit places an attachment glyph at the bottom edge of its bounds, not on
the baseline. The marker drawer read the first glyph's location as the
baseline, so an item that starts with a link pill, an inline image or inline
math drew its bullet, number or checkbox too low by however far the
attachment hangs below the text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(link-pill): add pill.lineHeight to keep stacked pills apart

A pill is as tall as its font's line plus its padding and border, and a
line grows to fit it, but only to the pill's own height. Pills on
consecutive lines therefore touch whenever the line height leaves no
room, which it rarely does with the default padding.

`pill.lineHeight` is a floor for the block that holds the pill: a
paragraph, list item, heading or quote gets lines at least that tall, on
every line, so its spacing stays even. It only raises the block's own
`lineHeight`, scales with the font like the block line heights do, and
is unset by default, which leaves every existing layout as it was.

Each platform folds it into the step where a block already sets its line
height. On Android the floor takes the flags of the block's own line
height span, so that it applies before the spans that add a margin to a
line.

For text that streams in, the docs recommend the block's own
`lineHeight` instead: a line height that depends on whether a pill is
there changes the moment a link becomes one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(link-pill): remove the native unit tests

Drops LinkPillSpanTest.kt (Android) and ENRMLinkPillAttachmentTests.mm
(iOS), and with them the wiring that existed only for those tests: the
example's test target goes back to how main sets it up (Podfile,
Podfile.lock, Xcode project, the iOS tests README), and the Android
library no longer includes resources in unit tests.

The Jest tests for pill style and content stay. So do the harness smoke
tests from main, which keep both native test jobs running.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(link-pill): drop null from the pill variant type

A variant either sets a pill or leaves it out; null was a third way to
say the same thing as false.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(android): release a pill's icon slot when its download fails

A remote icon that failed to load left its slot reserved for as long as the
document stayed static, so the pill showed a blank gap in front of its label.
The cache now reports the failure and the span gives the slot up, reflows its
hosts and has the component (or a table cell) re-measure, as iOS already did.

Also resolve pill content through one fallback helper: LinkPillStyle holds a
LinkPillContent, and the span and both ReadableMap parse sites go through its
orElse / fromReadableMap instead of spelling the chain out twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Julius Marminge <jmarminge@gmail.com>
Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>
Co-authored-by: Ernest Szlamczyk <127619251+eszlamczyk@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(link-pill): add optional icon tint (#921)

* feat(link-pill): add optional icon tint

`pill.iconTintColor` replaces the colors of a pill's icon and keeps its alpha;
omitting it keeps the image's own colors. It applies to local and remote icons
and leaves the cached image untouched.

The option and its Android and Jest tests come from #863, ported to the nested
`pill` configuration. Android sets the tint on the shared icon paint for every
icon, tinted or not, so one pill cannot tint the next.

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(link-pill): keep the variant tint off per-link icons

The variant's `pill.iconTintColor` tinted whatever icon a pill showed, so an
icon supplied through `linkPillContent` (an avatar, say) became a silhouette
in the tint color.

The variant tint now applies only to the variant's own icon. A
`linkPillContent` entry can carry its own `iconTintColor`: it tints the entry's
icon, or recolors the variant's icon for that one link when the entry has no
icon.

Also covers transparent and half-transparent tints in the iOS tests, documents
the rule, and adds an icon with a tint control to the Storybook pill story.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(ios): add long-press menus for links (#923)

* feat(ios): add long-press menus for links

`linkContextMenuItems` gives links a native menu on long press (iOS 17+). It is
keyed by URL pattern and matched like `linkVariants`, so one entry covers every
link of a kind:

  linkContextMenuItems={{
    '^https://example\\.com/files/': [
      { text: 'Copy path', icon: 'doc.on.doc', onPress: ({ url }) => copy(url) },
    ],
  }}

A link with a menu shows it instead of `onLinkLongPress` and the system
preview; other links keep their long-press behavior. The menu is titled with
the link's text or its pill label. It works in text, blockquotes and table
cells, where it lifts only the link.

Native hears about the menus only when what they show changes, not when
callbacks do. Android accepts the prop and ignores it.

Based on #865, reworked from menus keyed by exact URL to item lists keyed by
pattern.

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(ios): lift a pill as itself when its link menu opens

Without a preview UIKit greys the pressed text out, which hides a pill's own
look. A pill with a menu is now shown as its own image on a small platter in
the background color, as it already was in table cells.

Plain links keep the system highlight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ios): lift only the link when its menu opens in a table

Three problems with the link menu in tables:

- UIKit hides the view a preview is made of while the menu is open. The
  link was lifted through a visible path into the grid, so the whole
  table vanished behind the menu.
- A preview is drawn only from a view that is on screen, so a detached
  view stays blank until the menu appears. The link is now lifted as an
  image of itself laid over the grid, removed when the menu ends.
- UIKit replaces a nil identifier with one of its own, so the table's
  own menu was taken for a link menu: it lifted a sliver of the grid, or
  the last link pressed. The stored link frame now tells them apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ios): lift a pill as itself when its menu opens in text

A pill with a menu in a paragraph, list or blockquote was lifted by
UIKit as a text item: a padded box in the pill's colour first, then a
jump to a platter of our own once the menu opened.

The text view now presents that menu itself and gives UIKit none for
such a pill, so UIKit builds no box. The pill is lifted as an image of
itself laid over the text, and draws nothing in the text meanwhile, as
it would show through its lifted copy. New text takes the image down.

The table lifts its links the same way, so both now share
ENRMLinkMenuLift. The menu protocols live with the menu utilities, which
no longer need to know the text view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(input): expose link destination in StyleState so apps can display link target (#883)

* feat(input): expose link destination in StyleState

onChangeState and context-menu styleState.link now include destination
when the caret is inside a manual link, including at the half-open range
end index where iOS often places the caret.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(example): show StyleState JSON in the playground and add Maestro flow

The playground prints the latest onChangeState payload as JSON, and the
flow checks that tapping into a link reports its destination.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remerge link handling code

* refactor(android): route the context-menu link dialog through setLinkUrl

The dialog looked up the link with rangeOfType at the selection start and
applied it with addLinkDirect, bypassing the shared link-at-selection rule.
Use linkForSelection and setLinkUrl like JS setLink and the iOS link prompt,
and remove the now-unused applyLinkToRange and addLinkDirect.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(android): use onLinkClick in the Compose docs (#949)

* chore: ignore local .claude directory

* feat(web): add FormattingStore model for the web input

* feat(web): add range edit adjustment for the web input

* feat(web): apply text edits to the formatting store

* feat(web): add style toggling and atomic link selection to the store

* refactor(web): split web input model types into themed files

* fix: apply code review

* refactor(web): extract the sorted-range binary search

sortedInsertionIndex is one instance of the same lower-bound search the
other stores need, so it moves into a reusable helper rather than being
rewritten per store.

Created with usage of AI tools

---------

Co-authored-by: Maksymilian Galas <max@infinitypi.co>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Gregory Moskaliuk <mosckalyuck@gmail.com>
Co-authored-by: Jakub Lyczko <jakub.lyczko1@gmail.com>
Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>
Co-authored-by: Matt Voska <3444419+voska@users.noreply.github.com>
Co-authored-by: Matt Voska <voska@users.noreply.github.com>
Co-authored-by: Jan Nowakowski <56261019+jnowakow@users.noreply.github.com>
Co-authored-by: Ernest Szlamczyk <127619251+eszlamczyk@users.noreply.github.com>
Co-authored-by: Harry Yu <harry@wanderlog.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Trần Đình Huy <kingonwork@gmail.com>
Co-authored-by: Szymon Halski <halskiszymon@gmail.com>
Co-authored-by: Sullyvahnn-v2 <artur.wojnar@samorzad.agh.edu.pl>
Co-authored-by: Artur Wojnar <arturwojnar@Arturs-MacBook-Pro.local>
Co-authored-by: Julius Marminge <jmarminge@gmail.com>
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.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.

2 participants