Repository navigation
feat(mobile): render messages with gpt_markdown 1.3.4 plusparse #8945
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,11 @@ | ||
| import 'message_reply_drag_target.dart'; | ||
| import 'package:buzz/shared/widgets/feedback_snackbar.dart'; | ||
|
|
||
| import 'dart:async'; | ||
|
|
||
| import '../../shared/widgets/ios_media_carousel.dart'; | ||
| import '../../shared/widgets/message_attachment_corners.dart'; | ||
|
|
||
| import 'dart:io'; | ||
| import 'dart:math' as math; | ||
|
|
||
|
|
@@ -37,6 +40,7 @@ import 'local_send_bubble_surface.dart'; | |
| import 'media_viewer_page.dart'; | ||
| import 'message_content/link_normalizer.dart'; | ||
| import 'message_content/markdown_escapes.dart'; | ||
| import 'message_content/markdown_tables.dart'; | ||
| import 'message_content/preview_text.dart'; | ||
| import 'message_media.dart'; | ||
| import 'message_content/bubble_segments.dart'; | ||
|
|
@@ -299,10 +303,8 @@ class MessageContent extends HookConsumerWidget { | |
| ? extractTrailingImageGallery(normalizedContent, imetaByUrl) | ||
| : null; | ||
| final galleryText = trailingGallery?.content ?? normalizedContent; | ||
| // gpt_markdown has no backslash escapes, so hold escaped characters as | ||
| // stand-ins that _MarkdownEscapeMd and the link builders restore. | ||
| final markdownContent = useMemoized( | ||
| () => shieldMarkdownProseEscapes(galleryText), | ||
| () => separateMarkdownTables(escapeMarkdownMathDelimiters(galleryText)), | ||
| [galleryText], | ||
| ); | ||
| final customEmoji = _mergeCustomEmoji( | ||
|
|
@@ -340,8 +342,8 @@ class MessageContent extends HookConsumerWidget { | |
|
|
||
| final finalContent = useMemoized(() { | ||
| // Replace spaces with non-breaking spaces inside known mention names | ||
| // so the gpt_markdown combined regex can match multi-word names | ||
| // even when caseSensitive is not preserved. | ||
| // so _MentionMd's pattern matches multi-word names as one token, in | ||
| // the bound name's case. | ||
| // Skip content inside backticks to avoid altering inline code. | ||
| final mentionParts = markdownContent.split('`'); | ||
| final mentionBuf = StringBuffer(); | ||
|
|
@@ -363,17 +365,10 @@ class MessageContent extends HookConsumerWidget { | |
| mentionBuf.write(segment); | ||
| } | ||
| } | ||
| var result = mentionBuf.toString(); | ||
|
|
||
| // Ensure channel links at the very start of content don't get | ||
| // swallowed by markdown processing. | ||
| if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) { | ||
| result = '\u200B$result'; | ||
| } | ||
| return result; | ||
| return mentionBuf.toString(); | ||
| }, [markdownContent, mentionPresentationKey]); | ||
|
|
||
| final inlineComponents = _useMessageInlineComponents( | ||
| final inlinePatterns = _useMessageInlinePatterns( | ||
| content: content, | ||
| finalContent: finalContent, | ||
| mentionNames: resolvedMentionNames, | ||
|
|
@@ -410,41 +405,41 @@ class MessageContent extends HookConsumerWidget { | |
| : null, | ||
| followLinkColor: false, | ||
| // normalizeBareLinks() already turns bare URLs into Markdown links; | ||
| // gpt_markdown 1.2.0 autolinks by default, so both would run. | ||
| // gpt_markdown autolinks by default, so both would run. | ||
| autolink: false, | ||
| // Desktop has no GitHub alerts: a `> [!NOTE]` quote stays a quote. | ||
| alertBuilder: (_, alert) => alert.asBlockQuote(), | ||
| codeBuilder: (context, name, code, closed) => _MessageCodeBlock( | ||
| name: name, | ||
| code: code, | ||
| bubble: bubble, | ||
| ink: bubble && ownMessage ? ownTextColor : null, | ||
| ), | ||
| linkBuilder: (context, linkText, shieldedUrl, linkStyle) { | ||
| final url = unshieldMarkdownEscapes(shieldedUrl); | ||
| return _buildLink( | ||
| context, | ||
| // Same widget-shaped link as before: chips, media links and taps | ||
| // are handled in _buildLink, so the package's own tap is not used. | ||
| inlineLinkBuilder: (link) => link.asWidgetSpan( | ||
| _buildLink( | ||
| link.context, | ||
| ref, | ||
| linkText, | ||
| url, | ||
| imetaByUrl[url], | ||
| linkStyle, | ||
| TextSpan(children: link.labelSpans), | ||
| link.url, | ||
| imetaByUrl[link.url], | ||
| link.style, | ||
| style, | ||
| resolvedChannelTap, | ||
| resolvedChannelNames, | ||
| ); | ||
| }, | ||
| imageBuilder: (context, shieldedUrl, _, _) { | ||
| final imageUrl = unshieldMarkdownEscapes(shieldedUrl); | ||
| return _buildMedia( | ||
| context, | ||
| imageUrl, | ||
| imetaByUrl[imageUrl], | ||
| onReply: onMediaReply == null ? null : mediaReply, | ||
| onMore: onMediaMore == null ? null : mediaMore, | ||
| ); | ||
| }, | ||
| ), | ||
| ), | ||
| imageBuilder: (context, imageUrl, _, _) => _buildMedia( | ||
| context, | ||
| imageUrl, | ||
| imetaByUrl[imageUrl], | ||
| onReply: onMediaReply == null ? null : mediaReply, | ||
| onMore: onMediaMore == null ? null : mediaMore, | ||
| ), | ||
| textAlign: textAlign ?? (bubble ? TextAlign.start : null), | ||
| maxLines: maxLines, | ||
| inlineComponents: inlineComponents, | ||
| inlinePatterns: inlinePatterns, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a bubbled message uses newly supported reference-style media such as Useful? React with 👍 / 👎. |
||
| ), | ||
| ); | ||
| final segments = bubble | ||
|
|
@@ -687,8 +682,13 @@ class MessageContent extends HookConsumerWidget { | |
|
|
||
| // Mobile has no repo/PR/issue destination yet. Keep these presentation-only | ||
| // instead of exposing a control whose tap cannot do anything. | ||
| // The container keeps the chip's label its own node; without it, the | ||
| // non-interactive chip merges into the surrounding paragraph's label. | ||
| if (buzzLink is EntityDeepLink) { | ||
| return IgnorePointer(child: renderedLink); | ||
| return Semantics( | ||
| container: true, | ||
| child: IgnorePointer(child: renderedLink), | ||
| ); | ||
| } | ||
|
|
||
| return GestureDetector( | ||
|
|
@@ -1036,7 +1036,7 @@ class _MessageCodeBlock extends HookWidget { | |
| } | ||
| } | ||
|
|
||
| class _MentionMd extends InlineMd { | ||
| class _MentionMd { | ||
| final Map<String, Set<String>> bindings; | ||
| final Map<String, String> displayLabels; | ||
| final Map<String, String> mentionNames; | ||
|
|
@@ -1062,28 +1062,24 @@ class _MentionMd extends InlineMd { | |
| /// placeholder nested inside the link's own placeholder does not paint on | ||
| /// iOS — an authored `[@mention](url)` renders as nothing. Link resolution | ||
| /// wins over token detection inside a label. | ||
| @override | ||
| Set<MarkdownScope> get scopes => MarkdownComponent.allScopesExceptLinkLabel; | ||
| late final InlinePattern pattern = InlinePattern( | ||
| pattern: _exp, | ||
| builder: (context, match, style) => span(context, match[0]!, style), | ||
| ); | ||
|
|
||
| @override | ||
| RegExp get exp => _exp; | ||
|
|
||
| @override | ||
| InlineSpan span( | ||
| BuildContext context, | ||
| String text, | ||
| final GptMarkdownConfig config, | ||
| ) { | ||
| InlineSpan span(BuildContext context, String text, TextStyle style) { | ||
| final raw = exp.firstMatch(text.trim())?.group(0); | ||
| if (raw == null) { | ||
| return TextSpan(text: text, style: config.style); | ||
| return TextSpan(text: text, style: style); | ||
| } | ||
|
|
||
| final name = raw.substring(1).replaceAll('\u00A0', ' ').toLowerCase(); | ||
| final matches = bindings[name] ?? const <String>{}; | ||
| final pubkey = matches.length == 1 ? matches.single : null; | ||
| if (bindings.containsKey(name) && matches.length != 1) { | ||
| return TextSpan(text: text, style: config.style); | ||
| return TextSpan(text: text, style: style); | ||
| } | ||
| final displayName = name.contains(RegExp(r'\([0-9a-f]{64}\)')) | ||
| ? displayLabels[name] | ||
|
|
@@ -1100,7 +1096,7 @@ class _MentionMd extends InlineMd { | |
| label: visibleLabel, | ||
| semanticsLabel: fullLabel, | ||
| isAgent: isAgent, | ||
| textStyle: config.style, | ||
| textStyle: style, | ||
| ); | ||
|
|
||
| return WidgetSpan( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -186,15 +186,45 @@ bool _hasInlineCloserOnLine(String content, int start, int delimiterLength) { | |
| return false; | ||
| } | ||
|
|
||
| /// Whether a closing run follows before the paragraph ends: a code span | ||
| /// cannot continue past a blank line, a list item, heading, fence or a deeper | ||
| /// block quote. | ||
| bool _hasInlineCloser(String content, int start, int delimiterLength) { | ||
| final end = _paragraphEnd(content, start); | ||
| for (final run in _backtickRunPattern.allMatches(content, start)) { | ||
| if (run.start >= end) return false; | ||
| if (run.end - run.start == delimiterLength) return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| // Numeric entities shield literal label punctuation from gpt_markdown's | ||
| // bracket-blind image/link matchers and its earlier \[...\] math pass. | ||
| int _paragraphEnd(String content, int start) { | ||
| // A block quote can interrupt a paragraph, so a line opening a deeper quote | ||
| // than the paragraph's own line ends it. Equal or shallower markers continue | ||
| // it, as in a multi-line code span inside a quote. | ||
| final openerDepth = _lineQuoteDepth( | ||
| content, | ||
| start == 0 ? 0 : content.lastIndexOf('\n', start - 1) + 1, | ||
| ); | ||
| var newline = content.indexOf('\n', start); | ||
| while (newline >= 0) { | ||
| if (_lineQuoteDepth(content, newline + 1) > openerDepth || | ||
| _paragraphBreak.matchAsPrefix(content, newline + 1) != null) { | ||
| return newline; | ||
| } | ||
| newline = content.indexOf('\n', newline + 1); | ||
| } | ||
| return content.length; | ||
| } | ||
|
|
||
| final _paragraphBreak = RegExp( | ||
| r'[ \t]*(?:$|[-*+][ \t]|\d{1,9}[.)][ \t]|#{1,6}(?:[ \t]|$)|`{3}|~{3})', | ||
| multiLine: true, | ||
|
brow marked this conversation as resolved.
Resolved
Comment on lines
+220
to
+222
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an unmatched backtick precedes a setext underline, this expression does not treat Useful? React with 👍 / 👎. |
||
| ); | ||
|
|
||
| // Numeric entities keep literal label punctuation out of the line-based | ||
| // attachment, gallery and preview matchers, which do not balance brackets. | ||
| // The renderer decodes them. | ||
| String _normalizeMarkdownLabel(String label) => label.replaceAllMapped( | ||
| RegExp(r'\\([\\\[\]])|[\[\]]'), | ||
| (match) => '&#${(match[1] ?? match[0]!).codeUnitAt(0)};', | ||
|
|
@@ -349,10 +379,11 @@ String _normalizeProseLinks(String segment) { | |
| return normalized; | ||
| } | ||
|
|
||
| // An autolink keeps its backslashes literally. Encode them so none is read | ||
| // as an escape of the synthesized `]` or `)`. | ||
| // An autolink keeps its backslashes literally. Escape them so none is read | ||
| // as an escape of the synthesized `]` or `)`. A numeric entity would show | ||
| // verbatim: gpt_markdown leaves a URL-shaped label undecoded. | ||
| String _autolink(String url) => | ||
| '[${url.replaceAll(r'\', '\')}](${url.replaceAll(r'\', '%5C')})'; | ||
| '[${url.replaceAll(r'\', r'\\')}](${url.replaceAll(r'\', '%5C')})'; | ||
|
|
||
| String _normalizeBareLink(String segment, Match match) { | ||
| final matched = match[0]!; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a newly supported reference link has a normal HTTP definition such as
[docs][1]\n\n[1]: https://example.com/docs, the earliernormalizeBareLinks(content)call rewrites the definition destination into[https://example.com/docs](https://example.com/docs)before this plusparse pipeline receives it. The reference consequently resolves to that synthetic Markdown string rather than the HTTP URL, so_buildLinkdoes not open it; exclude reference-definition destinations from bare-link normalization before enabling reference-style parsing.Useful? React with 👍 / 👎.