fix(#235): render inline @[Name] mention chips + reply-gifs

Two shared-root render gaps (message tokens were only matched at fixed
positions):

- TranslatedMessageContent: parse @[Name] anywhere (leading, inline, or
  repeated) and render each as a chip; messages without a mention keep full
  link support (#233).
- GifHelper.parseGif: strip a leading `@[Name] ` reply prefix before matching
  so a reply-with-gif resolves to the gif instead of literal text; arbitrary
  text before a gif is still left as text (#232).

Tests: parseGif reply/format cases + widget tests for inline/leading/multiple
mention chips. Full suite 402 green.

Part of #233, #232.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/239/head
Strycher 1 week ago
parent b5e3d18688
commit edd9530c25

@ -10,7 +10,10 @@ class GifHelper {
/// ///
/// Returns null if text is not a valid GIF format /// Returns null if text is not a valid GIF format
static String? parseGif(String text) { static String? parseGif(String text) {
final trimmed = text.trim(); // A reply prepends a leading `@[Name] ` mention; ignore it so a reply-gif
// still resolves to the gif rather than plain text. Only a reply mention is
// stripped arbitrary text before a gif is left as text. (#232)
final trimmed = text.trim().replaceFirst(RegExp(r'^@\[[^\]]+\]\s+'), '');
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
if (match != null) { if (match != null) {
return match.group(1); return match.group(1);

@ -18,46 +18,51 @@ class TranslatedMessageContent extends StatelessWidget {
this.showOriginalFirst = true, this.showOriginalFirst = true,
}); });
// A leading `@[Name]` reply mention, with or without a trailing space. // An `@[Name]` mention anywhere in the text (leading, inline, or repeated).
static final RegExp _mentionPrefix = RegExp( // Rendered as a chip; surrounding text stays plain. (#233)
r'^@\[([^\]]+)\]\s*(.*)$', static final RegExp _mention = RegExp(r'@\[([^\]]+)\]');
dotAll: true,
);
Widget _buildText(BuildContext context, String text, TextStyle textStyle) { Widget _buildText(BuildContext context, String text, TextStyle textStyle) {
final match = _mentionPrefix.firstMatch(text); if (!_mention.hasMatch(text)) {
if (match == null) {
return LinkHandler.buildLinkifyText( return LinkHandler.buildLinkifyText(
context: context, context: context,
text: text, text: text,
style: textStyle, style: textStyle,
); );
} }
final name = match.group(1)!; // Mentions can't be interleaved with the Linkify widget, so a message that
final rest = match.group(2)!; // contains a mention renders as rich text with chip spans (links inside a
// mention message are not tappable same as the prior leading-mention
// path). Messages without a mention keep full link support above.
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Text.rich( final spans = <InlineSpan>[];
TextSpan( var last = 0;
children: [ for (final m in _mention.allMatches(text)) {
WidgetSpan( if (m.start > last) {
alignment: PlaceholderAlignment.middle, spans.add(TextSpan(text: text.substring(last, m.start)));
child: Container( }
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), spans.add(
decoration: BoxDecoration( WidgetSpan(
color: scheme.onSurface.withValues(alpha: 0.14), alignment: PlaceholderAlignment.middle,
borderRadius: BorderRadius.circular(6), child: Container(
), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
child: Text( decoration: BoxDecoration(
'@$name', color: scheme.onSurface.withValues(alpha: 0.14),
style: textStyle.copyWith(fontWeight: FontWeight.w500), borderRadius: BorderRadius.circular(6),
), ),
child: Text(
'@${m.group(1)!}',
style: textStyle.copyWith(fontWeight: FontWeight.w500),
), ),
), ),
if (rest.isNotEmpty) TextSpan(text: ' $rest'), ),
], );
), last = m.end;
style: textStyle, }
); if (last < text.length) {
spans.add(TextSpan(text: text.substring(last)));
}
return Text.rich(TextSpan(children: spans), style: textStyle);
} }
@override @override

@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/helpers/gif_helper.dart';
void main() {
group('GifHelper.parseGif reply/format handling', () {
test('g:ID form', () {
expect(GifHelper.parseGif('g:abc123'), 'abc123');
});
test('reply-gif: a leading @[Name] prefix is ignored (#232)', () {
expect(GifHelper.parseGif('@[Bob] g:abc123'), 'abc123');
expect(GifHelper.parseGif('@[Bob Smith] g:abc123'), 'abc123');
});
test('arbitrary text before a gif is NOT treated as a gif', () {
expect(GifHelper.parseGif('lol g:abc123'), isNull);
});
test('a reply with plain text is not a gif', () {
expect(GifHelper.parseGif('@[Bob] hello there'), isNull);
});
test('media.giphy.com URL resolves', () {
expect(
GifHelper.parseGif('https://media.giphy.com/media/abc123/giphy.gif'),
'abc123',
);
});
test('reply + gif URL resolves', () {
expect(
GifHelper.parseGif(
'@[Bob] https://media.giphy.com/media/abc123/giphy.gif',
),
'abc123',
);
});
test('plain text is null', () {
expect(GifHelper.parseGif('hello world'), isNull);
});
});
}

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/widgets/translated_message_content.dart';
Future<void> _pump(WidgetTester tester, String text) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TranslatedMessageContent(
displayText: text,
style: const TextStyle(fontSize: 14),
),
),
),
);
}
void main() {
group('TranslatedMessageContent mention rendering (#233)', () {
testWidgets('inline mention renders as a chip, not literal', (
tester,
) async {
await _pump(tester, 'hey @[Bob] how are you');
expect(find.text('@Bob'), findsOneWidget); // chip
expect(find.textContaining('@[Bob]'), findsNothing); // no literal
});
testWidgets('leading mention renders as a chip', (tester) async {
await _pump(tester, '@[Bob] hello');
expect(find.text('@Bob'), findsOneWidget);
});
testWidgets('multiple mentions each render as chips', (tester) async {
await _pump(tester, '@[Alice] and @[Bob]');
expect(find.text('@Alice'), findsOneWidget);
expect(find.text('@Bob'), findsOneWidget);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.