diff --git a/lib/helpers/gif_helper.dart b/lib/helpers/gif_helper.dart index 5b68e90..ce2b3f3 100644 --- a/lib/helpers/gif_helper.dart +++ b/lib/helpers/gif_helper.dart @@ -10,7 +10,10 @@ class GifHelper { /// /// Returns null if text is not a valid GIF format 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); if (match != null) { return match.group(1); diff --git a/lib/widgets/translated_message_content.dart b/lib/widgets/translated_message_content.dart index 607ffe7..db23561 100644 --- a/lib/widgets/translated_message_content.dart +++ b/lib/widgets/translated_message_content.dart @@ -18,46 +18,51 @@ class TranslatedMessageContent extends StatelessWidget { this.showOriginalFirst = true, }); - // A leading `@[Name]` reply mention, with or without a trailing space. - static final RegExp _mentionPrefix = RegExp( - r'^@\[([^\]]+)\]\s*(.*)$', - dotAll: true, - ); + // An `@[Name]` mention anywhere in the text (leading, inline, or repeated). + // Rendered as a chip; surrounding text stays plain. (#233) + static final RegExp _mention = RegExp(r'@\[([^\]]+)\]'); Widget _buildText(BuildContext context, String text, TextStyle textStyle) { - final match = _mentionPrefix.firstMatch(text); - if (match == null) { + if (!_mention.hasMatch(text)) { return LinkHandler.buildLinkifyText( context: context, text: text, style: textStyle, ); } - final name = match.group(1)!; - final rest = match.group(2)!; + // Mentions can't be interleaved with the Linkify widget, so a message that + // 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; - return Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: scheme.onSurface.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '@$name', - style: textStyle.copyWith(fontWeight: FontWeight.w500), - ), + final spans = []; + var last = 0; + for (final m in _mention.allMatches(text)) { + if (m.start > last) { + spans.add(TextSpan(text: text.substring(last, m.start))); + } + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: scheme.onSurface.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '@${m.group(1)!}', + style: textStyle.copyWith(fontWeight: FontWeight.w500), ), ), - if (rest.isNotEmpty) TextSpan(text: ' $rest'), - ], - ), - style: textStyle, - ); + ), + ); + last = m.end; + } + if (last < text.length) { + spans.add(TextSpan(text: text.substring(last))); + } + return Text.rich(TextSpan(children: spans), style: textStyle); } @override diff --git a/test/helpers/gif_helper_reply_test.dart b/test/helpers/gif_helper_reply_test.dart new file mode 100644 index 0000000..4e260a3 --- /dev/null +++ b/test/helpers/gif_helper_reply_test.dart @@ -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); + }); + }); +} diff --git a/test/widgets/translated_message_content_test.dart b/test/widgets/translated_message_content_test.dart new file mode 100644 index 0000000..129c6d8 --- /dev/null +++ b/test/widgets/translated_message_content_test.dart @@ -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 _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); + }); + }); +}