feat(#235): show the reply-to chip above a reply-gif

A reply prepends `@[Name] `, so a reply-gif renders the gif (prior commit);
this adds the `@Name` reply chip above it so it is clear the gif is a reply.
Extracts TranslatedMessageContent.mentionChip + leadingReplyName (shared by
inline rendering and the reply-gif header); the channel bubble builder renders
the chip above the GifMessage. DMs have no reply feature, so channel-only.

Part of #232.

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

@ -551,6 +551,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final hashWidth = context.read<MeshCoreConnector>().pathHashByteWidth; final hashWidth = context.read<MeshCoreConnector>().pathHashByteWidth;
final isOutgoing = message.isOutgoing; final isOutgoing = message.isOutgoing;
final gifId = GifHelper.parseGif(message.text); final gifId = GifHelper.parseGif(message.text);
final gifReplyName = gifId != null
? TranslatedMessageContent.leadingReplyName(message.text)
: null;
final poi = parseMarkerText(message.text); final poi = parseMarkerText(message.text);
final translatedDisplayText = final translatedDisplayText =
message.translatedText != null && message.translatedText != null &&
@ -638,22 +641,40 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
message.senderName, message.senderName,
) )
else if (gifId != null) else if (gifId != null)
Stack( Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
ClipRRect( if (gifReplyName != null) ...[
borderRadius: BorderRadius.circular(8), TranslatedMessageContent.mentionChip(
child: GifMessage( context,
url: gifReplyName,
'https://media.giphy.com/media/$gifId/giphy.gif', TextStyle(fontSize: bodyFontSize * textScale),
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
), ),
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),
),
),
],
), ),
], ],
) )

@ -22,6 +22,33 @@ class TranslatedMessageContent extends StatelessWidget {
// Rendered as a chip; surrounding text stays plain. (#233) // Rendered as a chip; surrounding text stays plain. (#233)
static final RegExp _mention = RegExp(r'@\[([^\]]+)\]'); 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) { Widget _buildText(BuildContext context, String text, TextStyle textStyle) {
if (!_mention.hasMatch(text)) { if (!_mention.hasMatch(text)) {
return LinkHandler.buildLinkifyText( return LinkHandler.buildLinkifyText(
@ -34,7 +61,6 @@ class TranslatedMessageContent extends StatelessWidget {
// contains a mention renders as rich text with chip spans (links inside a // contains a mention renders as rich text with chip spans (links inside a
// mention message are not tappable same as the prior leading-mention // mention message are not tappable same as the prior leading-mention
// path). Messages without a mention keep full link support above. // path). Messages without a mention keep full link support above.
final scheme = Theme.of(context).colorScheme;
final spans = <InlineSpan>[]; final spans = <InlineSpan>[];
var last = 0; var last = 0;
for (final m in _mention.allMatches(text)) { for (final m in _mention.allMatches(text)) {
@ -44,17 +70,7 @@ class TranslatedMessageContent extends StatelessWidget {
spans.add( spans.add(
WidgetSpan( WidgetSpan(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: Container( child: mentionChip(context, m.group(1)!, textStyle),
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),
),
),
), ),
); );
last = m.end; last = m.end;

@ -36,4 +36,20 @@ void main() {
expect(find.text('@Bob'), findsOneWidget); 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);
});
});
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.