diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index b455b0357..3f618506d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3009,6 +3009,8 @@ "@incomingMessages": {}, "stickers": "Stickers", "@stickers": {}, + "gifs": "GIFs", + "@gifs": {}, "discover": "Discover", "@discover": {}, "commandHint_ignore": "Ignore the given matrix ID", diff --git a/lib/pages/chat/chat_emoji_picker.dart b/lib/pages/chat/chat_emoji_picker.dart index be845bddd..60ce5e131 100644 --- a/lib/pages/chat/chat_emoji_picker.dart +++ b/lib/pages/chat/chat_emoji_picker.dart @@ -5,6 +5,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat/gif_picker_dialog.dart'; import 'package:fluffychat/pages/chat/sticker_picker_dialog.dart'; import 'chat.dart'; @@ -25,13 +26,14 @@ class ChatEmojiPicker extends StatelessWidget { : 0, child: controller.showEmojiPicker ? DefaultTabController( - length: 2, + length: 3, child: Column( children: [ TabBar( tabs: [ Tab(text: L10n.of(context).emojis), Tab(text: L10n.of(context).stickers), + Tab(text: L10n.of(context).gifs), ], ), Expanded( @@ -85,6 +87,25 @@ class ChatEmojiPicker extends StatelessWidget { controller.hideEmojiPicker(); }, ), + GifPickerDialog( + onSelected: (gif) { + controller.room.sendEvent( + { + 'body': + gif.title.isNotEmpty ? gif.title : 'GIF', + 'info': { + 'mimetype': 'image/gif', + 'w': gif.width, + 'h': gif.height, + 'size': null, + }, + 'url': gif.url, + }, + type: EventTypes.Sticker, + ); + controller.hideEmojiPicker(); + }, + ), ], ), ), diff --git a/lib/pages/chat/gif_picker_dialog.dart b/lib/pages/chat/gif_picker_dialog.dart new file mode 100644 index 000000000..b5532d8c0 --- /dev/null +++ b/lib/pages/chat/gif_picker_dialog.dart @@ -0,0 +1,388 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/utils/tenor_api.dart'; + +class GifPickerDialog extends StatefulWidget { + final void Function(TenorGif) onSelected; + + const GifPickerDialog({ + required this.onSelected, + super.key, + }); + + @override + GifPickerDialogState createState() => GifPickerDialogState(); +} + +class GifPickerDialogState extends State { + String? searchFilter; + List gifs = []; + bool isLoading = false; + bool hasError = false; + String? errorMessage; + Timer? _debounceTimer; + final ScrollController _scrollController = ScrollController(); + String _nextPos = '0'; + bool _hasMoreResults = true; + bool _isLoadingMore = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _loadFeaturedGifs(); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 300 && + !_isLoadingMore && + _hasMoreResults && + !isLoading) { + _loadMoreGifs(); + } + } + + Future _loadFeaturedGifs() async { + setState(() { + isLoading = true; + hasError = false; + _nextPos = '0'; + _hasMoreResults = true; + }); + + try { + final response = await TenorApi.getFeaturedGifs(); + setState(() { + gifs = response.results; + isLoading = false; + _nextPos = response.next; + _hasMoreResults = response.next != '0' && response.results.isNotEmpty; + }); + } catch (e) { + setState(() { + isLoading = false; + hasError = true; + errorMessage = e.toString(); + }); + } + } + + Future _searchGifs(String query) async { + setState(() { + isLoading = true; + hasError = false; + _nextPos = '0'; + _hasMoreResults = true; + }); + + try { + final response = await TenorApi.searchGifs(query); + setState(() { + gifs = response.results; + isLoading = false; + _nextPos = response.next; + _hasMoreResults = response.next != '0' && response.results.isNotEmpty; + }); + } catch (e) { + setState(() { + isLoading = false; + hasError = true; + errorMessage = e.toString(); + }); + } + } + + Future _loadMoreGifs() async { + if (_isLoadingMore || !_hasMoreResults || isLoading || _nextPos == '0') { + return; + } + + setState(() { + _isLoadingMore = true; + }); + + try { + final response = searchFilter?.isNotEmpty == true + ? await TenorApi.searchGifs(searchFilter!, pos: _nextPos) + : await TenorApi.getFeaturedGifs(pos: _nextPos); + + setState(() { + gifs.addAll(response.results); + _nextPos = response.next; + _hasMoreResults = response.next != '0' && response.results.isNotEmpty; + _isLoadingMore = false; + }); + } catch (e) { + setState(() { + _isLoadingMore = false; + // Don't disable _hasMoreResults on error, allow retry + }); + } + } + + void _onSearchChanged(String query) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 500), () { + setState(() => searchFilter = query); + if (query.trim().isEmpty) { + _loadFeaturedGifs(); + } else { + _searchGifs(query); + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + backgroundColor: theme.colorScheme.onInverseSurface, + body: SizedBox( + width: double.maxFinite, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAppBar( + floating: true, + pinned: true, + scrolledUnderElevation: 0, + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + title: SizedBox( + height: 42, + child: TextField( + autofocus: false, + decoration: InputDecoration( + filled: true, + hintText: L10n.of(context).search, + prefixIcon: const Icon(Icons.search_outlined), + contentPadding: EdgeInsets.zero, + ), + onChanged: _onSearchChanged, + ), + ), + ), + if (isLoading && gifs.isEmpty) + const SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(), + ), + ) + else if (hasError) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text( + L10n.of(context).oopsSomethingWentWrong, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + errorMessage ?? '', + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + if (searchFilter?.isNotEmpty == true) { + _searchGifs(searchFilter!); + } else { + _loadFeaturedGifs(); + } + }, + child: Text(L10n.of(context).tryAgain), + ), + ], + ), + ), + ) + else if (gifs.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.gif_box_outlined, + size: 64, + color: + theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + L10n.of(context).nothingFound, + style: theme.textTheme.titleMedium, + ), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.all(8.0), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + MediaQuery.of(context).size.width > 600 ? 3 : 2, + crossAxisSpacing: 8.0, + mainAxisSpacing: 8.0, + childAspectRatio: 1.0, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index >= gifs.length) { + return const SizedBox.shrink(); + } + + final gif = gifs[index]; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => widget.onSelected(gif), + borderRadius: BorderRadius.circular(8.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: theme.colorScheme.outline + .withValues(alpha: 0.2), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + gif.previewUrl, + fit: BoxFit.cover, + loadingBuilder: + (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + strokeWidth: 2.0, + value: loadingProgress + .expectedTotalBytes != + null + ? loadingProgress + .cumulativeBytesLoaded / + loadingProgress + .expectedTotalBytes! + : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + color: theme.colorScheme + .surfaceContainerHighest, + child: Icon( + Icons.broken_image, + color: theme + .colorScheme.onSurfaceVariant, + ), + ); + }, + ), + // GIF indicator + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 2.0, + ), + decoration: BoxDecoration( + color: + Colors.black.withValues(alpha: 0.7), + borderRadius: + BorderRadius.circular(4.0), + ), + child: Text( + 'GIF', + style: theme.textTheme.labelSmall + ?.copyWith( + color: Colors.white, + fontSize: 10, + ), + ), + ), + ), + if (gif.title.isNotEmpty) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black + .withValues(alpha: 0.7), + Colors.transparent, + ], + ), + ), + padding: const EdgeInsets.all(8.0), + child: Text( + gif.title, + style: theme.textTheme.bodySmall + ?.copyWith( + color: Colors.white, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + childCount: gifs.length, + ), + ), + ), + if (_isLoadingMore) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/utils/tenor_api.dart b/lib/utils/tenor_api.dart new file mode 100644 index 000000000..9e9018a51 --- /dev/null +++ b/lib/utils/tenor_api.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +class TenorGif { + final String id; + final String title; + final String url; + final String previewUrl; + final int width; + final int height; + + TenorGif({ + required this.id, + required this.title, + required this.url, + required this.previewUrl, + required this.width, + required this.height, + }); + + factory TenorGif.fromJson(Map json) { + final mediaFormats = json['media_formats'] as Map; + + // Find gif and tinygif formats - v2 API uses media_formats structure + final gifFormat = mediaFormats['gif'] as Map?; + final previewFormat = + mediaFormats['tinygif'] as Map? ?? gifFormat; + + if (gifFormat == null) { + throw Exception('No GIF format found in media_formats'); + } + + return TenorGif( + id: json['id'], + title: json['title'] ?? json['content_description'] ?? '', + url: gifFormat['url'], + previewUrl: previewFormat!['url'], + width: gifFormat['dims'][0], + height: gifFormat['dims'][1], + ); + } +} + +class TenorApiResponse { + final List results; + final String next; + + TenorApiResponse({ + required this.results, + required this.next, + }); + + factory TenorApiResponse.fromJson(Map json) { + final results = (json['results'] as List) + .map((item) => TenorGif.fromJson(item)) + .toList(); + + return TenorApiResponse( + results: results, + next: json['next']?.toString() ?? '', + ); + } +} + +class TenorApi { + static const String _baseUrl = 'https://tenor.googleapis.com/v2'; + static const String _apiKey = + ''; // Test API key from documentation + static const String _clientKey = + 'fluffychat_app'; // Client key for integration tracking + static const int _limit = 20; + + static Future searchGifs( + String query, { + String? pos, + }) async { + if (query.trim().isEmpty) { + return getFeaturedGifs(pos: pos); + } + + try { + final queryParams = { + 'key': _apiKey, + 'client_key': _clientKey, + 'q': query, + 'limit': _limit.toString(), + 'locale': 'en_US', + 'country': 'US', + 'contentfilter': 'medium', + 'media_filter': 'gif,tinygif', + 'ar_range': 'all', + }; + + if (pos != null && pos.isNotEmpty && pos != '0') { + queryParams['pos'] = pos; + } + + final uri = + Uri.parse('$_baseUrl/search').replace(queryParameters: queryParams); + final response = await http.get(uri); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + return TenorApiResponse.fromJson(data); + } else { + throw Exception('Failed to search GIFs: ${response.statusCode}'); + } + } catch (e) { + throw Exception('Error searching GIFs: $e'); + } + } + + static Future getFeaturedGifs({String? pos}) async { + try { + final queryParams = { + 'key': _apiKey, + 'client_key': _clientKey, + 'limit': _limit.toString(), + 'locale': 'en_US', + 'country': 'US', + 'contentfilter': 'medium', + 'media_filter': 'gif,tinygif', + 'ar_range': 'all', + }; + + if (pos != null && pos.isNotEmpty && pos != '0') { + queryParams['pos'] = pos; + } + + final uri = + Uri.parse('$_baseUrl/featured').replace(queryParameters: queryParams); + final response = await http.get(uri); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + return TenorApiResponse.fromJson(data); + } else { + throw Exception('Failed to get featured GIFs: ${response.statusCode}'); + } + } catch (e) { + throw Exception('Error getting featured GIFs: $e'); + } + } +} diff --git a/lib/widgets/mxc_image.dart b/lib/widgets/mxc_image.dart index 1572fa6b9..6e860137b 100644 --- a/lib/widgets/mxc_image.dart +++ b/lib/widgets/mxc_image.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; @@ -75,36 +76,79 @@ class _MxcImageState extends State { final event = widget.event; if (uri != null) { - final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); - final width = widget.width; - final realWidth = width == null ? null : width * devicePixelRatio; - final height = widget.height; - final realHeight = height == null ? null : height * devicePixelRatio; + // Handle direct HTTP/HTTPS URLs + if (uri.scheme == 'http' || uri.scheme == 'https') { + try { + final response = await http.get(uri); + if (response.statusCode == 200) { + if (!mounted) return; + setState(() { + _imageData = response.bodyBytes; + }); + return; + } + } catch (e) { + Logs().w('Failed to load HTTP image: $uri', e); + } + } else if (uri.scheme == 'mxc') { + // Handle MXC URLs + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final width = widget.width; + final realWidth = width == null ? null : width * devicePixelRatio; + final height = widget.height; + final realHeight = height == null ? null : height * devicePixelRatio; - final remoteData = await client.downloadMxcCached( - uri, - width: realWidth, - height: realHeight, - thumbnailMethod: widget.thumbnailMethod, - isThumbnail: widget.isThumbnail, - animated: widget.animated, - ); - if (!mounted) return; - setState(() { - _imageData = remoteData; - }); + final remoteData = await client.downloadMxcCached( + uri, + width: realWidth, + height: realHeight, + thumbnailMethod: widget.thumbnailMethod, + isThumbnail: widget.isThumbnail, + animated: widget.animated, + ); + if (!mounted) return; + setState(() { + _imageData = remoteData; + }); + } } if (event != null) { - final data = await event.downloadAndDecryptAttachment( - getThumbnail: widget.isThumbnail, - ); - if (data.detectFileType is MatrixImageFile || widget.isThumbnail) { - if (!mounted) return; - setState(() { - _imageData = data.bytes; - }); - return; + // Check if the event has a direct HTTP URL + final eventUrl = event.content.tryGet('url'); + if (eventUrl != null) { + final eventUri = Uri.tryParse(eventUrl); + if (eventUri != null && + (eventUri.scheme == 'http' || eventUri.scheme == 'https')) { + try { + final response = await http.get(eventUri); + if (response.statusCode == 200) { + if (!mounted) return; + setState(() { + _imageData = response.bodyBytes; + }); + return; + } + } catch (e) { + Logs().w('Failed to load HTTP image from event: $eventUrl', e); + } + } + } + + // Fallback to Matrix attachment handling for MXC URLs + try { + final data = await event.downloadAndDecryptAttachment( + getThumbnail: widget.isThumbnail, + ); + if (data.detectFileType is MatrixImageFile || widget.isThumbnail) { + if (!mounted) return; + setState(() { + _imageData = data.bytes; + }); + return; + } + } catch (e) { + Logs().w('Failed to download Matrix attachment', e); } } }