diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 9defb93..2d3c207 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -551,6 +551,9 @@ class _ChannelChatScreenState extends State { final hashWidth = context.read().pathHashByteWidth; final isOutgoing = message.isOutgoing; final gifId = GifHelper.parseGif(message.text); + final gifReplyName = gifId != null + ? TranslatedMessageContent.leadingReplyName(message.text) + : null; final poi = parseMarkerText(message.text); final translatedDisplayText = message.translatedText != null && @@ -638,22 +641,40 @@ class _ChannelChatScreenState extends State { message.senderName, ) else if (gifId != null) - Stack( + Column( + crossAxisAlignment: isOutgoing + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: Colors.transparent, - fallbackTextColor: isOutgoing - ? Theme.of(context) - .colorScheme - .onPrimaryContainer - .withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurface - .withValues(alpha: 0.6), + if (gifReplyName != null) ...[ + TranslatedMessageContent.mentionChip( + context, + gifReplyName, + TextStyle(fontSize: bodyFontSize * textScale), ), + const SizedBox(height: 4), + ], + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GifMessage( + url: + 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: Colors.transparent, + fallbackTextColor: isOutgoing + ? Theme.of(context) + .colorScheme + .onPrimaryContainer + .withValues(alpha: 0.7) + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ], ), ], ) diff --git a/lib/widgets/translated_message_content.dart b/lib/widgets/translated_message_content.dart index db23561..e4b751e 100644 --- a/lib/widgets/translated_message_content.dart +++ b/lib/widgets/translated_message_content.dart @@ -22,6 +22,33 @@ class TranslatedMessageContent extends StatelessWidget { // Rendered as a chip; surrounding text stays plain. (#233) static final RegExp _mention = RegExp(r'@\[([^\]]+)\]'); + // A leading `@[Name] ` reply prefix. + static final RegExp _replyPrefix = RegExp(r'^@\[([^\]]+)\]\s+'); + + /// The name of a leading `@[Name]` reply mention, or null. Lets callers show + /// a reply chip above content that isn't rendered as text (e.g. a reply-gif, + /// #232). + static String? leadingReplyName(String text) => + _replyPrefix.firstMatch(text.trim())?.group(1); + + /// A styled `@Name` mention chip, shared by inline text rendering and reply + /// headers above non-text content. + static Widget mentionChip( + BuildContext context, + String name, + TextStyle style, + ) { + final scheme = Theme.of(context).colorScheme; + return 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: style.copyWith(fontWeight: FontWeight.w500)), + ); + } + Widget _buildText(BuildContext context, String text, TextStyle textStyle) { if (!_mention.hasMatch(text)) { return LinkHandler.buildLinkifyText( @@ -34,7 +61,6 @@ class TranslatedMessageContent extends StatelessWidget { // 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 spans = []; var last = 0; for (final m in _mention.allMatches(text)) { @@ -44,17 +70,7 @@ class TranslatedMessageContent extends StatelessWidget { 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), - ), - ), + child: mentionChip(context, m.group(1)!, textStyle), ), ); last = m.end; diff --git a/test/widgets/translated_message_content_test.dart b/test/widgets/translated_message_content_test.dart index 129c6d8..491792c 100644 --- a/test/widgets/translated_message_content_test.dart +++ b/test/widgets/translated_message_content_test.dart @@ -36,4 +36,20 @@ void main() { expect(find.text('@Bob'), findsOneWidget); }); }); + + group('TranslatedMessageContent.leadingReplyName (#232)', () { + test('extracts a space-terminated leading reply name', () { + expect(TranslatedMessageContent.leadingReplyName('@[Bob] hi'), 'Bob'); + expect( + TranslatedMessageContent.leadingReplyName('@[Bob Smith] g:x'), + 'Bob Smith', + ); + }); + + test('null when not leading or no trailing space', () { + expect(TranslatedMessageContent.leadingReplyName('hi @[Bob]'), isNull); + expect(TranslatedMessageContent.leadingReplyName('@[Bob]'), isNull); + expect(TranslatedMessageContent.leadingReplyName('hello'), isNull); + }); + }); }