diff --git a/lib/helpers/gif_helper.dart b/lib/helpers/gif_helper.dart new file mode 100644 index 00000000..8dd187b1 --- /dev/null +++ b/lib/helpers/gif_helper.dart @@ -0,0 +1,38 @@ +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? parseGif(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); + } + + /// 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 628ae1cc..7beaaf4c 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.parseGif(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.parseGif(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( @@ -897,7 +892,7 @@ class _ChannelChatScreenState extends State { isScrollControlled: true, builder: (context) => GifPicker( onGifSelected: (gifId) { - _textController.text = 'g:$gifId'; + _textController.text = GifHelper.encodeGif(gifId); }, ), ); @@ -1053,7 +1048,7 @@ class _ChannelChatScreenState extends State { child: ValueListenableBuilder( valueListenable: _textController, builder: (context, value, child) { - final gifId = _parseGifId(value.text); + final gifId = GifHelper.parseGif(value.text); if (gifId != null) { return Focus( autofocus: true, @@ -1321,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 372e3e7c..8057f1f5 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.parseGif(value.text); if (gifId != null) { return Focus( autofocus: true, @@ -598,19 +599,13 @@ 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); - } - void _showGifPicker(BuildContext context) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => GifPicker( onGifSelected: (gifId) { - _textController.text = 'g:$gifId'; + _textController.text = GifHelper.encodeGif(gifId); }, ), ); @@ -1543,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); } } @@ -1573,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.parseGif(message.text); final poi = _parsePoiMessage(message.text); final isFailed = message.status == MessageStatus.failed; final bubbleColor = isFailed @@ -1847,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( 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:')); }