镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 46 additions & 50 deletions mobile/lib/features/channels/message_content.dart
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;

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve reference-definition destinations before plusparse

When a newly supported reference link has a normal HTTP definition such as [docs][1]\n\n[1]: https://example.com/docs, the earlier normalizeBareLinks(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 _buildLink does not open it; exclude reference-definition destinations from bare-link normalization before enabling reference-style parsing.

Useful? React with 👍 / 👎.

[galleryText],
);
final customEmoji = _mergeCustomEmoji(
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle reference-style images in attachment preprocessing

When a bubbled message uses newly supported reference-style media such as ![shot][1]\n\n[1]: https://example.com/shot.png, this plusparse path resolves it and invokes imageBuilder, but messageBubbleSegments() and extractTrailingImageGallery() still recognize only inline ![...](...) syntax. The image therefore remains inside the text bubble instead of receiving attachment corners, and multiple trailing reference images do not form a gallery; resolve reference definitions before classifying attachments or extend both preprocessors to understand this syntax.

Useful? React with 👍 / 👎.

),
);
final segments = bubble
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -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]
Expand All @@ -1100,7 +1096,7 @@ class _MentionMd extends InlineMd {
label: visibleLabel,
semanticsLabel: fullLabel,
isAgent: isAgent,
textStyle: config.style,
textStyle: style,
);

return WidgetSpan(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ List<({String text, bool attachment})> messageBubbleSegments(
final isAttachment =
match != null &&
(line.trim().startsWith('!') ||
// Content arrives with escapes shielded; imeta keys are plain.
imeta.containsKey(unshieldMarkdownEscapes(match.group(1)!)));
// CommonMark unescapes link destinations; imeta keys are plain.
imeta.containsKey(
unshieldMarkdownEscapes(shieldMarkdownEscapes(match.group(1)!)),
));
if (attachment != null &&
isAttachment != attachment &&
line.trim().isNotEmpty) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
part of '../message_content.dart';

List<MarkdownComponent> _useMessageInlineComponents({
List<InlinePattern> _useMessageInlinePatterns({
required String content,
required String finalContent,
required Map<String, String> mentionNames,
Expand Down Expand Up @@ -46,7 +46,7 @@ List<MarkdownComponent> _useMessageInlineComponents({
hasMentionHandler: onMentionTap != null,
);

// gpt_markdown compares components by identity. Recreating them on every
// gpt_markdown compares patterns by identity. Recreating them on every
// parent rebuild reparses unchanged message bodies on the UI isolate.
return useMemoized(
() => [
Expand All @@ -60,16 +60,16 @@ List<MarkdownComponent> _useMessageInlineComponents({
},
agentMentionPubkeys: inputs.agentPubkeys,
onMentionTap: inputs.hasMentionHandler ? mentionTap : null,
),
CustomEmojiMd(inputs.customEmoji, content: finalContent, size: emojiSize),
).pattern,
CustomEmojiMd(
inputs.customEmoji,
content: finalContent,
size: emojiSize,
).pattern,
_ChannelLinkMd(
channelNames: inputs.channelNames,
onChannelTap: channelTap,
),
_MarkdownLabelEntityMd(),
_MarkdownEscapeMd(),
for (final component in MarkdownComponent.inlineComponents)
component is ATagMd ? _MessageLinkMd() : component,
).pattern,
],
[inputs],
);
Expand Down Expand Up @@ -144,41 +144,3 @@ class _InlineComponentInputs {
int get hashCode =>
Object.hash(content, finalContent, emojiSize, hasMentionHandler);
}

// Encoded label punctuation must remain literal even inside nested formatting.
class _MarkdownLabelEntityMd extends InlineMd {
@override
RegExp get exp => RegExp(r'&#(?:91|92|93);');

@override
Set<MarkdownScope> get scopes => const {MarkdownScope.linkLabel};

@override
InlineSpan span(
BuildContext context,
String text,
GptMarkdownConfig config,
) => TextSpan(text: decodeMarkdownLabelSyntax(text), style: config.style);
}

// Backslash-escaped characters, held by shieldMarkdownEscapes so no other
// syntax matches them, shown as themselves.
class _MarkdownEscapeMd extends InlineMd {
@override
RegExp get exp => RegExp('(?:${shieldedMarkdownEscape.pattern})+');

@override
InlineSpan span(
BuildContext context,
String text,
GptMarkdownConfig config,
) => TextSpan(text: unshieldMarkdownEscapes(text), style: config.style);
}

// Labels are already normalized, including literal/nested brackets. The
// package's permissive .*? label matcher can otherwise start at a prose '['
// and swallow a later image or link before its own component gets to render.
class _MessageLinkMd extends ATagMd {
@override
RegExp get exp => RegExp(r'(?<!!)\[[^\[\]\n]*\]\([^\s]*\)');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
brow marked this conversation as resolved.
Resolved
Comment on lines +220 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recognize setext underlines as paragraph boundaries

When an unmatched backtick precedes a setext underline, this expression does not treat --- or === as the end of the paragraph. For example, title \n---\nhttps://example.com code`` pairs the title's backtick with the one in the later paragraph, causing normalizeBareLinksto skip that URL; plusparse then renders the heading and later paragraph separately, andautolink: false` leaves the URL non-clickable. Add setext/thematic-break lines to the paragraph terminators.

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)};',
Expand Down Expand Up @@ -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'\', '&#92;')}](${url.replaceAll(r'\', '%5C')})';
'[${url.replaceAll(r'\', r'\\')}](${url.replaceAll(r'\', '%5C')})';

String _normalizeBareLink(String segment, Match match) {
final matched = match[0]!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,30 @@ const _marker = '\u2060';
const _fullwidthOffset = 0xFEE0;
// CommonMark: a backslash escapes any ASCII punctuation character.
final _escape = RegExp(r'\\([!-/:-@\[-`{-~])|\u2060');

/// [content] with `\[`, `\]`, `\(` and `\)` outside code written as numeric
/// entities.
///
/// gpt_markdown reads those pairs as LaTeX delimiters, but in CommonMark, and
/// on desktop, they are escapes for literal brackets and parentheses. The
/// renderer decodes the entities as text, so `\[not a link\](x)` shows
/// `[not a link](x)` instead of a formula. `\\` stays an escaped backslash.
String escapeMarkdownMathDelimiters(String content) =>
_mathDelimiter.hasMatch(content)
? mapMarkdownProse(
content,
(prose) => prose.replaceAllMapped(
_backslashPair,
(m) => switch (m[1]) {
'[' => '&#91;',
']' => '&#93;',
'(' => '&#40;',
')' => '&#41;',
_ => m[0]!,
},
),
)
: content;

final _mathDelimiter = RegExp(r'\\[\[\]()]');
final _backslashPair = RegExp(r'\\(.)', dotAll: true);
Loading
Loading