From 45658a7612de311b7bb2dba456c09eebfa6d10d9 Mon Sep 17 00:00:00 2001 From: Adam Novak Date: Sun, 5 Apr 2026 22:39:20 -0400 Subject: [PATCH 1/5] Understand more kinds of Giphy reference as GIF This adds Giphy page URLs and `media.giphy.com` URLs (with and without protocols) as *accepted* encodings for GIF messages, alongside the `g:` syntax. When someone posts such a URL by itself as a message, it will be rendered inline just like `g:` messages are now. This does not change the encoding that GIF messages are *sent* in; that is still the `g:` syntax. --- lib/screens/chat_screen.dart | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 372e3e7c..ec1116c2 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -601,7 +601,23 @@ class _ChatScreenState extends State { String? _parseGifId(String text) { final trimmed = text.trim(); final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); - return match?.group(1); + if (match != null) { + return match.group(1); + } + final directUrlMatch = RegExp( + r'^(?:https?://)?media\.giphy\.com/media/([A-Za-z0-9_-]+)/giphy\.gif$', + ).firstMatch(trimmed); + if (directUrlMatch != null) { + return directUrlMatch.group(1); + } + // Giphy understands page URLs with just the ID, or any string and a + // dash before the ID, and redirects to a page with a dash-separated + // title, a dash, and the ID. IDs in this form *probably* can't + // contain dashes. + final pageMatch = RegExp( + r'^(?:https?://)?giphy\.com/gifs/(?:[^/?]*-)?([A-Za-z0-9_]+)/?$', + ).firstMatch(trimmed); + return pageMatch?.group(1); } void _showGifPicker(BuildContext context) { From 45c9823c6f715493c77be016775e243b870489a5 Mon Sep 17 00:00:00 2001 From: Adam Novak Date: Sun, 5 Apr 2026 22:51:48 -0400 Subject: [PATCH 2/5] Escape forward slashes in regexes --- lib/screens/chat_screen.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index ec1116c2..398e1b5f 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -605,7 +605,7 @@ class _ChatScreenState extends State { return match.group(1); } final directUrlMatch = RegExp( - r'^(?:https?://)?media\.giphy\.com/media/([A-Za-z0-9_-]+)/giphy\.gif$', + r'^(?:https?:\/\/)?media\.giphy\.com\/media\/([A-Za-z0-9_-]+)\/giphy\.gif$', ).firstMatch(trimmed); if (directUrlMatch != null) { return directUrlMatch.group(1); @@ -615,7 +615,7 @@ class _ChatScreenState extends State { // title, a dash, and the ID. IDs in this form *probably* can't // contain dashes. final pageMatch = RegExp( - r'^(?:https?://)?giphy\.com/gifs/(?:[^/?]*-)?([A-Za-z0-9_]+)/?$', + r'^(?:https?:\/\/)?giphy\.com\/gifs\/(?:[^/?]*-)?([A-Za-z0-9_]+)\/?$', ).firstMatch(trimmed); return pageMatch?.group(1); } From 75ec3b6116eeab412e08bed116e813544ab0bfa6 Mon Sep 17 00:00:00 2001 From: Adam Novak Date: Mon, 6 Apr 2026 01:55:50 -0400 Subject: [PATCH 3/5] Centralize GIF parsing in a helper like for reactions --- lib/helpers/gif_helper.dart | 33 ++++++++++++++++++++++++++++ lib/screens/channel_chat_screen.dart | 13 ++++------- lib/screens/chat_screen.dart | 33 +++------------------------- 3 files changed, 40 insertions(+), 39 deletions(-) create mode 100644 lib/helpers/gif_helper.dart diff --git a/lib/helpers/gif_helper.dart b/lib/helpers/gif_helper.dart new file mode 100644 index 00000000..a223ffc6 --- /dev/null +++ b/lib/helpers/gif_helper.dart @@ -0,0 +1,33 @@ +class GifHelper { + /// Parse a known GIF format, which can be any of: + /// g:GIFID + /// https://media.giphy.com/media/GIFID/giphy.gif + /// https://giphy.com/gifs/Optional-title-with-dashes-GIFID + /// + /// GIFID is a Giphy GIF ID. The https:// is optional (and + /// can also be http://). The giphy.com/gifs form can also + /// include a trailing slash. + /// + /// Returns null if text is not a valid GIF format + static String? parseGifId(String text) { + final trimmed = text.trim(); + final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); + if (match != null) { + return match.group(1); + } + final directUrlMatch = RegExp( + r'^(?:https?:\/\/)?media\.giphy\.com\/media\/([A-Za-z0-9_-]+)\/giphy\.gif$', + ).firstMatch(trimmed); + if (directUrlMatch != null) { + return directUrlMatch.group(1); + } + // Giphy understands page URLs with just the ID, or any string and a + // dash before the ID, and redirects to a page with a dash-separated + // title, a dash, and the ID. IDs in this form *probably* can't + // contain dashes. + final pageMatch = RegExp( + r'^(?:https?:\/\/)?giphy\.com\/gifs\/(?:[^/?]*-)?([A-Za-z0-9_]+)\/?$', + ).firstMatch(trimmed); + return pageMatch?.group(1); + } +} diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 628ae1cc..131d74c0 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -11,6 +11,7 @@ import '../connector/meshcore_connector.dart'; import '../utils/platform_info.dart'; import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; +import '../helpers/gif_helper.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/utf8_length_limiter.dart'; import '../l10n/l10n.dart'; @@ -355,7 +356,7 @@ class _ChannelChatScreenState extends State { final settingsService = context.watch(); final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; - final gifId = _parseGifId(message.text); + final gifId = GifHelper.parseGifId(message.text); final poi = _parsePoiMessage(message.text); final translatedDisplayText = message.translatedText != null && @@ -699,7 +700,7 @@ class _ChannelChatScreenState extends State { final colorScheme = Theme.of(context).colorScheme; final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7); - final gifId = _parseGifId(replyText); + final gifId = GifHelper.parseGifId(replyText); final poi = _parsePoiMessage(replyText); Widget contentPreview; @@ -811,12 +812,6 @@ class _ChannelChatScreenState extends State { ); } - String? _parseGifId(String text) { - final trimmed = text.trim(); - final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); - return match?.group(1); - } - _PoiInfo? _parsePoiMessage(String text) { final trimmed = text.trim(); final match = RegExp( @@ -1053,7 +1048,7 @@ class _ChannelChatScreenState extends State { child: ValueListenableBuilder( valueListenable: _textController, builder: (context, value, child) { - final gifId = _parseGifId(value.text); + final gifId = GifHelper.parseGifId(value.text); if (gifId != null) { return Focus( autofocus: true, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 398e1b5f..daba56b0 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -16,6 +16,7 @@ import '../connector/meshcore_protocol.dart'; import '../helpers/reaction_helper.dart'; import '../widgets/message_status_icon.dart'; import '../helpers/chat_scroll_controller.dart'; +import '../helpers/gif_helper.dart'; import '../helpers/path_helper.dart'; import '../helpers/utf8_length_limiter.dart'; import '../models/channel_message.dart'; @@ -523,7 +524,7 @@ class _ChatScreenState extends State { child: ValueListenableBuilder( valueListenable: _textController, builder: (context, value, child) { - final gifId = _parseGifId(value.text); + final gifId = GifHelper.parseGifId(value.text); if (gifId != null) { return Focus( autofocus: true, @@ -598,28 +599,6 @@ class _ChatScreenState extends State { ); } - String? _parseGifId(String text) { - final trimmed = text.trim(); - final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); - if (match != null) { - return match.group(1); - } - final directUrlMatch = RegExp( - r'^(?:https?:\/\/)?media\.giphy\.com\/media\/([A-Za-z0-9_-]+)\/giphy\.gif$', - ).firstMatch(trimmed); - if (directUrlMatch != null) { - return directUrlMatch.group(1); - } - // Giphy understands page URLs with just the ID, or any string and a - // dash before the ID, and redirects to a page with a dash-separated - // title, a dash, and the ID. IDs in this form *probably* can't - // contain dashes. - final pageMatch = RegExp( - r'^(?:https?:\/\/)?giphy\.com\/gifs\/(?:[^/?]*-)?([A-Za-z0-9_]+)\/?$', - ).firstMatch(trimmed); - return pageMatch?.group(1); - } - void _showGifPicker(BuildContext context) { showModalBottomSheet( context: context, @@ -1589,7 +1568,7 @@ class _MessageBubble extends StatelessWidget { final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; final colorScheme = Theme.of(context).colorScheme; - final gifId = _parseGifId(message.text); + final gifId = GifHelper.parseGifId(message.text); final poi = _parsePoiMessage(message.text); final isFailed = message.status == MessageStatus.failed; final bubbleColor = isFailed @@ -1863,12 +1842,6 @@ class _MessageBubble extends StatelessWidget { ); } - String? _parseGifId(String text) { - final trimmed = text.trim(); - final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); - return match?.group(1); - } - _PoiInfo? _parsePoiMessage(String text) { final trimmed = text.trim(); final match = RegExp( From c5ec60638cff225af4cfcfc40d5fc378236650ff Mon Sep 17 00:00:00 2001 From: Adam Novak Date: Mon, 6 Apr 2026 02:09:40 -0400 Subject: [PATCH 4/5] Put reaction and GIF helpers in charge of encoding --- lib/helpers/gif_helper.dart | 7 ++++++- lib/helpers/reaction_helper.dart | 5 +++++ lib/screens/channel_chat_screen.dart | 10 +++++----- lib/screens/chat_screen.dart | 8 ++++---- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/helpers/gif_helper.dart b/lib/helpers/gif_helper.dart index a223ffc6..8dd187b1 100644 --- a/lib/helpers/gif_helper.dart +++ b/lib/helpers/gif_helper.dart @@ -9,7 +9,7 @@ class GifHelper { /// include a trailing slash. /// /// Returns null if text is not a valid GIF format - static String? parseGifId(String text) { + static String? parseGif(String text) { final trimmed = text.trim(); final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); if (match != null) { @@ -30,4 +30,9 @@ class GifHelper { ).firstMatch(trimmed); return pageMatch?.group(1); } + + /// Encode a GIF in a format that parseGif() can parse. + static String encodeGif(String gifId) { + return 'g:$gifId'; + } } diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index 90733c3a..169b1a14 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -109,4 +109,9 @@ class ReactionHelper { return ReactionInfo(targetHash: match.group(1)!, emoji: emoji); } + + /// Encode a reaction message that parseReaction() can parse. + static String encodeReaction(String hash, String emojiIndex) { + return 'r:$hash:$emojiIndex'; + } } diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 131d74c0..7beaaf4c 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -356,7 +356,7 @@ class _ChannelChatScreenState extends State { final settingsService = context.watch(); final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; - final gifId = GifHelper.parseGifId(message.text); + final gifId = GifHelper.parseGif(message.text); final poi = _parsePoiMessage(message.text); final translatedDisplayText = message.translatedText != null && @@ -700,7 +700,7 @@ class _ChannelChatScreenState extends State { final colorScheme = Theme.of(context).colorScheme; final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7); - final gifId = GifHelper.parseGifId(replyText); + final gifId = GifHelper.parseGif(replyText); final poi = _parsePoiMessage(replyText); Widget contentPreview; @@ -892,7 +892,7 @@ class _ChannelChatScreenState extends State { isScrollControlled: true, builder: (context) => GifPicker( onGifSelected: (gifId) { - _textController.text = 'g:$gifId'; + _textController.text = GifHelper.encodeGif(gifId); }, ), ); @@ -1048,7 +1048,7 @@ class _ChannelChatScreenState extends State { child: ValueListenableBuilder( valueListenable: _textController, builder: (context, value, child) { - final gifId = GifHelper.parseGifId(value.text); + final gifId = GifHelper.parseGif(value.text); if (gifId != null) { return Focus( autofocus: true, @@ -1316,7 +1316,7 @@ class _ChannelChatScreenState extends State { message.senderName, message.text, ); - final reactionText = 'r:$hash:$emojiIndex'; + final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex); connector.sendChannelMessage(widget.channel, reactionText); } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index daba56b0..8057f1f5 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -524,7 +524,7 @@ class _ChatScreenState extends State { child: ValueListenableBuilder( valueListenable: _textController, builder: (context, value, child) { - final gifId = GifHelper.parseGifId(value.text); + final gifId = GifHelper.parseGif(value.text); if (gifId != null) { return Focus( autofocus: true, @@ -605,7 +605,7 @@ class _ChatScreenState extends State { isScrollControlled: true, builder: (context) => GifPicker( onGifSelected: (gifId) { - _textController.text = 'g:$gifId'; + _textController.text = GifHelper.encodeGif(gifId); }, ), ); @@ -1538,7 +1538,7 @@ class _ChatScreenState extends State { senderName, message.text, ); - final reactionText = 'r:$hash:$emojiIndex'; + final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex); connector.sendMessage(_resolveContact(connector), reactionText); } } @@ -1568,7 +1568,7 @@ class _MessageBubble extends StatelessWidget { final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; final colorScheme = Theme.of(context).colorScheme; - final gifId = GifHelper.parseGifId(message.text); + final gifId = GifHelper.parseGif(message.text); final poi = _parsePoiMessage(message.text); final isFailed = message.status == MessageStatus.failed; final bubbleColor = isFailed From 08ffb978cf32fe31caef024c345fd97be6b60782 Mon Sep 17 00:00:00 2001 From: Zach Date: Mon, 6 Apr 2026 14:26:42 -0700 Subject: [PATCH 5/5] fix: gif trnslat --- lib/services/translation_service.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index f8147a11..7d76efab 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -6,6 +6,7 @@ import 'package:llamadart/llamadart.dart'; import '../models/app_settings.dart'; import '../models/translation_support.dart'; +import '../helpers/gif_helper.dart'; import '../utils/app_logger.dart'; import 'app_settings_service.dart'; import 'translation_file_store.dart'; @@ -509,8 +510,10 @@ class TranslationService extends ChangeNotifier { if (trimmed.isEmpty) { return false; } - return !(trimmed.startsWith('g:') || - trimmed.startsWith('m:') || + if (GifHelper.parseGif(trimmed) != null) { + return false; + } + return !(trimmed.startsWith('m:') || trimmed.startsWith('V1|') || trimmed.startsWith('r:')); }