diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 7edc547..8fe42be 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -285,6 +285,9 @@ func main() { auth.POST("/reset-password", authHandler.ResetPassword) } + // Image proxy — public (no auth needed so CachedNetworkImage can fetch without Bearer token) + v1.GET("/image-proxy", mediaHandler.ImageProxy) + authorized := v1.Group("") authorized.Use(middleware.AuthMiddleware(cfg.JWTSecret, dbPool)) { @@ -404,8 +407,6 @@ func main() { // Media routes authorized.POST("/upload", mediaHandler.Upload) authorized.GET("/media/sign", mediaHandler.GetSignedMediaURL) - authorized.GET("/image-proxy", mediaHandler.ImageProxy) - // Search & Discover routes discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService) authorized.GET("/search", discoverHandler.Search) diff --git a/go-backend/internal/handlers/media_handler.go b/go-backend/internal/handlers/media_handler.go index c0dc203..2345fb2 100644 --- a/go-backend/internal/handlers/media_handler.go +++ b/go-backend/internal/handlers/media_handler.go @@ -288,6 +288,8 @@ func (h *MediaHandler) ImageProxy(c *gin.Context) { "https://preview.redd.it/", "https://external-preview.redd.it/", "https://blob.gifcities.org/gifcities/", + "https://i.imgur.com/", + "https://media.giphy.com/", } { if strings.HasPrefix(rawURL, prefix) { allowed = true diff --git a/sojorn_app/lib/config/api_config.dart b/sojorn_app/lib/config/api_config.dart index ba7215a..f3e7444 100644 --- a/sojorn_app/lib/config/api_config.dart +++ b/sojorn_app/lib/config/api_config.dart @@ -40,6 +40,8 @@ class ApiConfig { return url.startsWith('https://i.redd.it/') || url.startsWith('https://preview.redd.it/') || url.startsWith('https://external-preview.redd.it/') || - url.startsWith('https://blob.gifcities.org/gifcities/'); + url.startsWith('https://blob.gifcities.org/gifcities/') || + url.startsWith('https://i.imgur.com/') || + url.startsWith('https://media.giphy.com/'); } } diff --git a/sojorn_app/lib/screens/clusters/group_chat_tab.dart b/sojorn_app/lib/screens/clusters/group_chat_tab.dart index bf8efa8..0b5f423 100644 --- a/sojorn_app/lib/screens/clusters/group_chat_tab.dart +++ b/sojorn_app/lib/screens/clusters/group_chat_tab.dart @@ -7,7 +7,7 @@ import '../../services/capsule_security_service.dart'; import '../../services/content_guard_service.dart'; import '../../theme/tokens.dart'; import '../../theme/app_theme.dart'; -import '../../widgets/gif/gif_picker.dart'; +import '../../widgets/composer/composer_bar.dart'; class GroupChatTab extends StatefulWidget { final String groupId; @@ -28,12 +28,9 @@ class GroupChatTab extends StatefulWidget { } class _GroupChatTabState extends State { - final TextEditingController _msgCtrl = TextEditingController(); final ScrollController _scrollCtrl = ScrollController(); List> _messages = []; bool _loading = true; - bool _sending = false; - String? _pendingGif; // GIF URL staged before send @override void initState() { @@ -43,7 +40,6 @@ class _GroupChatTabState extends State { @override void dispose() { - _msgCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } @@ -103,12 +99,7 @@ class _GroupChatTabState extends State { _messages = decrypted.reversed.toList(); } - Future _sendMessage() async { - final text = _msgCtrl.text.trim(); - final gif = _pendingGif; - if (text.isEmpty && gif == null) return; - if (_sending) return; - + Future _onChatSend(String text, String? gifUrl) async { if (text.isNotEmpty) { // Local content guard — block before encryption final guardReason = ContentGuardService.instance.check(text); @@ -118,7 +109,7 @@ class _GroupChatTabState extends State { SnackBar(content: Text(guardReason), backgroundColor: Colors.red), ); } - return; + throw Exception('blocked'); // prevents ComposerBar from clearing } // Server-side AI moderation — stateless, nothing stored @@ -129,46 +120,36 @@ class _GroupChatTabState extends State { SnackBar(content: Text(aiReason), backgroundColor: Colors.red), ); } - return; + throw Exception('blocked'); } } - setState(() => _sending = true); - try { - final payload = { - 'text': text, - 'ts': DateTime.now().toIso8601String(), - if (gif != null) 'gif_url': gif, - }; + final payload = { + 'text': text, + 'ts': DateTime.now().toIso8601String(), + if (gifUrl != null) 'gif_url': gifUrl, + }; - if (widget.isEncrypted && widget.capsuleKey != null) { - final encrypted = await CapsuleSecurityService.encryptPayload( - payload: payload, - capsuleKey: widget.capsuleKey!, - ); - await ApiService.instance.callGoApi( - '/capsules/${widget.groupId}/entries', - method: 'POST', - body: { - 'iv': encrypted.iv, - 'encrypted_payload': encrypted.encryptedPayload, - 'data_type': 'chat', - 'key_version': 1, - }, - ); - } else { - await ApiService.instance.sendGroupMessage(widget.groupId, - body: text.isNotEmpty ? text : gif ?? ''); - } - _msgCtrl.clear(); - setState(() => _pendingGif = null); - await _loadMessages(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e'))); - } + if (widget.isEncrypted && widget.capsuleKey != null) { + final encrypted = await CapsuleSecurityService.encryptPayload( + payload: payload, + capsuleKey: widget.capsuleKey!, + ); + await ApiService.instance.callGoApi( + '/capsules/${widget.groupId}/entries', + method: 'POST', + body: { + 'iv': encrypted.iv, + 'encrypted_payload': encrypted.encryptedPayload, + 'data_type': 'chat', + 'key_version': 1, + }, + ); + } else { + await ApiService.instance.sendGroupMessage( + widget.groupId, body: text.isNotEmpty ? text : gifUrl ?? ''); } - if (mounted) setState(() => _sending = false); + if (mounted) await _loadMessages(); } void _reportMessage(Map msg) { @@ -341,92 +322,11 @@ class _GroupChatTabState extends State { ), child: SafeArea( top: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // GIF preview - if (_pendingGif != null) - Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.network( - ApiConfig.needsProxy(_pendingGif!) - ? ApiConfig.proxyImageUrl(_pendingGif!) - : _pendingGif!, - height: 100, fit: BoxFit.cover), - ), - Positioned( - top: 4, right: 4, - child: GestureDetector( - onTap: () => setState(() => _pendingGif = null), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.black54, - ), - padding: const EdgeInsets.all(2), - child: const Icon(Icons.close, color: Colors.white, size: 14), - ), - ), - ), - ], - ), - ), - Row( - children: [ - // GIF button - GestureDetector( - onTap: () => showGifPicker( - context, - onSelected: (url) => setState(() => _pendingGif = url), - ), - child: Container( - width: 36, height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppTheme.navyBlue.withValues(alpha: 0.07), - ), - child: Icon(Icons.gif_outlined, color: AppTheme.textSecondary, size: 22), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: _msgCtrl, - style: TextStyle(color: SojornColors.postContent, fontSize: 14), - decoration: InputDecoration( - hintText: widget.isEncrypted ? 'Encrypted message…' : 'Type a message…', - hintStyle: TextStyle(color: SojornColors.textDisabled), - filled: true, - fillColor: AppTheme.scaffoldBg, - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none), - ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(), - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: _sendMessage, - child: Container( - width: 40, height: 40, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _sending ? AppTheme.brightNavy.withValues(alpha: 0.5) : AppTheme.brightNavy, - ), - child: _sending - ? const Padding(padding: EdgeInsets.all(10), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite)) - : const Icon(Icons.send, color: SojornColors.basicWhite, size: 18), - ), - ), - ], - ), - ], + child: ComposerBar( + config: widget.isEncrypted + ? const ComposerConfig(hintText: 'Encrypted message…') + : ComposerConfig.chat, + onSend: _onChatSend, ), ), ), diff --git a/sojorn_app/lib/screens/clusters/group_feed_tab.dart b/sojorn_app/lib/screens/clusters/group_feed_tab.dart index 80b70d6..6a3ac31 100644 --- a/sojorn_app/lib/screens/clusters/group_feed_tab.dart +++ b/sojorn_app/lib/screens/clusters/group_feed_tab.dart @@ -1,14 +1,11 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:cryptography/cryptography.dart'; -import 'package:image_picker/image_picker.dart'; import '../../config/api_config.dart'; import '../../services/api_service.dart'; import '../../services/capsule_security_service.dart'; -import '../../services/image_upload_service.dart'; import '../../theme/tokens.dart'; import '../../theme/app_theme.dart'; -import '../../widgets/gif/gif_picker.dart'; +import '../../widgets/composer/composer_bar.dart'; class GroupFeedTab extends StatefulWidget { final String groupId; @@ -29,15 +26,8 @@ class GroupFeedTab extends StatefulWidget { } class _GroupFeedTabState extends State { - final TextEditingController _postCtrl = TextEditingController(); List> _posts = []; bool _loading = true; - bool _posting = false; - - // Image / GIF attachment (public groups only) - File? _pickedImage; - String? _pendingImageUrl; // already-uploaded URL (from GIF or uploaded file) - bool _uploading = false; @override void initState() { @@ -47,39 +37,9 @@ class _GroupFeedTabState extends State { @override void dispose() { - _postCtrl.dispose(); super.dispose(); } - Future _pickImage() async { - final picker = ImagePicker(); - final xf = await picker.pickImage(source: ImageSource.gallery); - if (xf == null) return; - setState(() { _pickedImage = File(xf.path); _pendingImageUrl = null; }); - } - - void _attachGif(String gifUrl) { - setState(() { _pickedImage = null; _pendingImageUrl = gifUrl; }); - } - - void _clearAttachment() { - setState(() { _pickedImage = null; _pendingImageUrl = null; }); - } - - Future _resolveImageUrl() async { - if (_pendingImageUrl != null) return _pendingImageUrl; - if (_pickedImage != null) { - setState(() => _uploading = true); - try { - final url = await ImageUploadService().uploadImage(_pickedImage!); - return url; - } finally { - if (mounted) setState(() => _uploading = false); - } - } - return null; - } - Future _loadPosts() async { setState(() => _loading = true); try { @@ -136,44 +96,30 @@ class _GroupFeedTabState extends State { _posts = decrypted; } - Future _createPost() async { - final text = _postCtrl.text.trim(); - final hasAttachment = _pickedImage != null || _pendingImageUrl != null; - if ((text.isEmpty && !hasAttachment) || _posting) return; - setState(() => _posting = true); - try { - if (widget.isEncrypted && widget.capsuleKey != null) { - final encrypted = await CapsuleSecurityService.encryptPayload( - payload: {'text': text, 'ts': DateTime.now().toIso8601String()}, - capsuleKey: widget.capsuleKey!, - ); - await ApiService.instance.callGoApi( - '/capsules/${widget.groupId}/entries', - method: 'POST', - body: { - 'iv': encrypted.iv, - 'encrypted_payload': encrypted.encryptedPayload, - 'data_type': 'post', - 'key_version': 1, - }, - ); - } else { - final imageUrl = await _resolveImageUrl(); - await ApiService.instance.createGroupPost( - widget.groupId, - body: text, - imageUrl: imageUrl, - ); - } - _postCtrl.clear(); - _clearAttachment(); - await _loadPosts(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to post: $e'))); - } + Future _onComposerSend(String text, String? mediaUrl) async { + if (widget.isEncrypted && widget.capsuleKey != null) { + final encrypted = await CapsuleSecurityService.encryptPayload( + payload: {'text': text, 'ts': DateTime.now().toIso8601String()}, + capsuleKey: widget.capsuleKey!, + ); + await ApiService.instance.callGoApi( + '/capsules/${widget.groupId}/entries', + method: 'POST', + body: { + 'iv': encrypted.iv, + 'encrypted_payload': encrypted.encryptedPayload, + 'data_type': 'post', + 'key_version': 1, + }, + ); + } else { + await ApiService.instance.createGroupPost( + widget.groupId, + body: text, + imageUrl: mediaUrl, + ); } - if (mounted) setState(() => _posting = false); + if (mounted) await _loadPosts(); } Future _toggleLike(String postId, int index) async { @@ -214,99 +160,11 @@ class _GroupFeedTabState extends State { color: AppTheme.cardSurface, border: Border(bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.06))), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - CircleAvatar( - radius: 18, - backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1), - child: Icon(Icons.person, size: 18, color: AppTheme.brightNavy), - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - controller: _postCtrl, - style: TextStyle(color: SojornColors.postContent, fontSize: 14), - decoration: InputDecoration( - hintText: widget.isEncrypted ? 'Write an encrypted post…' : 'Write something…', - hintStyle: TextStyle(color: SojornColors.textDisabled), - filled: true, - fillColor: AppTheme.scaffoldBg, - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none), - ), - textInputAction: TextInputAction.newline, - maxLines: null, - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: (_posting || _uploading) ? null : _createPost, - child: Container( - width: 36, height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: (_posting || _uploading) - ? AppTheme.brightNavy.withValues(alpha: 0.5) - : AppTheme.brightNavy, - ), - child: (_posting || _uploading) - ? const Padding(padding: EdgeInsets.all(9), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite)) - : const Icon(Icons.send, color: SojornColors.basicWhite, size: 16), - ), - ), - ], - ), - // Attachment buttons (public groups only) + preview - if (!widget.isEncrypted) ...[ - const SizedBox(height: 8), - Row( - children: [ - _MediaBtn( - icon: Icons.image_outlined, - label: 'Photo', - onTap: _pickImage, - ), - const SizedBox(width: 8), - _MediaBtn( - icon: Icons.gif_outlined, - label: 'GIF', - onTap: () => showGifPicker(context, onSelected: _attachGif), - ), - if (_pickedImage != null || _pendingImageUrl != null) ...[ - const Spacer(), - GestureDetector( - onTap: _clearAttachment, - child: Icon(Icons.cancel, size: 18, color: AppTheme.textSecondary), - ), - ], - ], - ), - // Attachment preview - if (_pickedImage != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.file(_pickedImage!, height: 120, fit: BoxFit.cover), - ), - ), - if (_pendingImageUrl != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.network( - ApiConfig.needsProxy(_pendingImageUrl!) - ? ApiConfig.proxyImageUrl(_pendingImageUrl!) - : _pendingImageUrl!, - height: 120, fit: BoxFit.cover), - ), - ), - ], - ], + child: ComposerBar( + config: widget.isEncrypted + ? ComposerConfig.privatePost + : ComposerConfig.publicPost, + onSend: _onComposerSend, ), ), // Posts list @@ -485,10 +343,8 @@ class _CommentsSheet extends StatefulWidget { } class _CommentsSheetState extends State<_CommentsSheet> { - final _commentCtrl = TextEditingController(); List> _comments = []; bool _loading = true; - bool _sending = false; @override void initState() { @@ -496,9 +352,6 @@ class _CommentsSheetState extends State<_CommentsSheet> { _loadComments(); } - @override - void dispose() { _commentCtrl.dispose(); super.dispose(); } - Future _loadComments() async { setState(() => _loading = true); try { @@ -507,16 +360,9 @@ class _CommentsSheetState extends State<_CommentsSheet> { if (mounted) setState(() => _loading = false); } - Future _sendComment() async { - final text = _commentCtrl.text.trim(); - if (text.isEmpty || _sending) return; - setState(() => _sending = true); - try { - await ApiService.instance.createGroupPostComment(widget.groupId, widget.postId, body: text); - _commentCtrl.clear(); - await _loadComments(); - } catch (_) {} - if (mounted) setState(() => _sending = false); + Future _sendComment(String text, String? _) async { + await ApiService.instance.createGroupPostComment(widget.groupId, widget.postId, body: text); + await _loadComments(); } @override @@ -567,34 +413,9 @@ class _CommentsSheetState extends State<_CommentsSheet> { ), ), const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: _commentCtrl, - style: TextStyle(color: SojornColors.postContent, fontSize: 13), - decoration: InputDecoration( - hintText: 'Write a comment…', - hintStyle: TextStyle(color: SojornColors.textDisabled), - filled: true, fillColor: AppTheme.scaffoldBg, - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none), - ), - onSubmitted: (_) => _sendComment(), - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: _sendComment, - child: Container( - width: 34, height: 34, - decoration: BoxDecoration(shape: BoxShape.circle, color: AppTheme.brightNavy), - child: _sending - ? const Padding(padding: EdgeInsets.all(8), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite)) - : const Icon(Icons.send, color: SojornColors.basicWhite, size: 14), - ), - ), - ], + ComposerBar( + config: ComposerConfig.comment, + onSend: _sendComment, ), ], ), @@ -602,35 +423,3 @@ class _CommentsSheetState extends State<_CommentsSheet> { } } -class _MediaBtn extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; - const _MediaBtn({required this.icon, required this.label, required this.onTap}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: AppTheme.navyBlue.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: AppTheme.textSecondary), - const SizedBox(width: 4), - Text(label, - style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - fontWeight: FontWeight.w500)), - ], - ), - ), - ); - } -} diff --git a/sojorn_app/lib/screens/clusters/group_thread_detail_screen.dart b/sojorn_app/lib/screens/clusters/group_thread_detail_screen.dart index 415162a..36279d4 100644 --- a/sojorn_app/lib/screens/clusters/group_thread_detail_screen.dart +++ b/sojorn_app/lib/screens/clusters/group_thread_detail_screen.dart @@ -3,6 +3,7 @@ import 'package:cryptography/cryptography.dart'; import '../../services/api_service.dart'; import '../../theme/tokens.dart'; import '../../theme/app_theme.dart'; +import '../../widgets/composer/composer_bar.dart'; /// Thread detail screen with replies — works for both public and encrypted groups. /// For encrypted groups, thread detail isn't supported via the standard API yet, @@ -28,11 +29,9 @@ class GroupThreadDetailScreen extends StatefulWidget { } class _GroupThreadDetailScreenState extends State { - final _replyCtrl = TextEditingController(); Map? _thread; List> _replies = []; bool _loading = true; - bool _sending = false; @override void initState() { @@ -40,12 +39,6 @@ class _GroupThreadDetailScreenState extends State { _loadThread(); } - @override - void dispose() { - _replyCtrl.dispose(); - super.dispose(); - } - Future _loadThread() async { setState(() => _loading = true); try { @@ -65,20 +58,9 @@ class _GroupThreadDetailScreenState extends State { if (mounted) setState(() => _loading = false); } - Future _sendReply() async { - final text = _replyCtrl.text.trim(); - if (text.isEmpty || _sending || widget.isEncrypted) return; - setState(() => _sending = true); - try { - await ApiService.instance.createGroupThreadReply(widget.groupId, widget.threadId, body: text); - _replyCtrl.clear(); - await _loadThread(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e'))); - } - } - if (mounted) setState(() => _sending = false); + Future _sendReply(String text, String? _) async { + await ApiService.instance.createGroupThreadReply(widget.groupId, widget.threadId, body: text); + await _loadThread(); } int _uniqueParticipants() { @@ -217,34 +199,9 @@ class _GroupThreadDetailScreenState extends State { color: AppTheme.cardSurface, border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))), ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _replyCtrl, - style: TextStyle(color: SojornColors.postContent, fontSize: 14), - decoration: InputDecoration( - hintText: 'Add to this chain…', - hintStyle: TextStyle(color: SojornColors.textDisabled), - filled: true, fillColor: AppTheme.scaffoldBg, - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none), - ), - onSubmitted: (_) => _sendReply(), - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: _sendReply, - child: Container( - width: 38, height: 38, - decoration: BoxDecoration(shape: BoxShape.circle, color: AppTheme.brightNavy), - child: _sending - ? const Padding(padding: EdgeInsets.all(9), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite)) - : const Icon(Icons.send, color: SojornColors.basicWhite, size: 16), - ), - ), - ], + child: ComposerBar( + config: ComposerConfig.threadReply, + onSend: _sendReply, ), ), ], diff --git a/sojorn_app/lib/widgets/composer/composer_bar.dart b/sojorn_app/lib/widgets/composer/composer_bar.dart new file mode 100644 index 0000000..9a1f752 --- /dev/null +++ b/sojorn_app/lib/widgets/composer/composer_bar.dart @@ -0,0 +1,312 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../config/api_config.dart'; +import '../../services/image_upload_service.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/tokens.dart'; +import '../gif/gif_picker.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// ComposerConfig — controls which options are visible per context +// ───────────────────────────────────────────────────────────────────────────── + +class ComposerConfig { + final bool allowImages; + final bool allowGifs; + final String hintText; + final int? maxLines; + + const ComposerConfig({ + this.allowImages = false, + this.allowGifs = false, + this.hintText = 'Write something…', + this.maxLines, + }); + + bool get hasMedia => allowImages || allowGifs; + + // ── Presets ────────────────────────────────────────────────────────────── + + /// Public group post — images + GIFs allowed. + static const publicPost = ComposerConfig(allowImages: true, allowGifs: true); + + /// Encrypted capsule post — text only. + static const privatePost = ComposerConfig(hintText: 'Write an encrypted post…'); + + /// Public comment / reply — GIF allowed, no image upload. + static const comment = ComposerConfig(allowGifs: true, hintText: 'Add a comment…'); + + /// Encrypted comment / reply or thread reply — text only. + static const textOnly = ComposerConfig(hintText: 'Add a comment…'); + + /// Thread detail reply — text only. + static const threadReply = ComposerConfig(hintText: 'Add to this chain…'); + + /// Public group chat message — GIF allowed. + static const chat = ComposerConfig(allowGifs: true, hintText: 'Message…'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// ComposerBar +// ───────────────────────────────────────────────────────────────────────────── + +/// Unified text + media composer used throughout the app. +/// +/// [onSend] receives the trimmed text and an optional resolved media URL. +/// Image upload and GIF picking are handled internally. On a successful +/// [onSend] the text and attachment are automatically cleared. +/// +/// Pass [externalController] when the parent must access or clear the text +/// independently (e.g. reply state in quips sheet). Don't dispose it while +/// this widget is still mounted — ComposerBar will not dispose an external +/// controller. +class ComposerBar extends StatefulWidget { + final ComposerConfig config; + final Future Function(String text, String? mediaUrl) onSend; + final TextEditingController? externalController; + final FocusNode? focusNode; + + const ComposerBar({ + required this.config, + required this.onSend, + this.externalController, + this.focusNode, + super.key, + }); + + @override + State createState() => _ComposerBarState(); +} + +class _ComposerBarState extends State { + late final TextEditingController _ctrl; + File? _mediaFile; + String? _mediaUrl; + bool _uploading = false; + bool _sending = false; + + bool get _hasAttachment => _mediaFile != null || _mediaUrl != null; + + @override + void initState() { + super.initState(); + _ctrl = widget.externalController ?? TextEditingController(); + } + + @override + void dispose() { + if (widget.externalController == null) _ctrl.dispose(); + super.dispose(); + } + + Future _pickImage() async { + final picker = ImagePicker(); + final xf = await picker.pickImage(source: ImageSource.gallery); + if (xf == null || !mounted) return; + setState(() { + _mediaFile = File(xf.path); + _mediaUrl = null; + }); + } + + void _attachGif(String gifUrl) { + setState(() { + _mediaFile = null; + _mediaUrl = gifUrl; + }); + } + + void _clearAttachment() { + setState(() { + _mediaFile = null; + _mediaUrl = null; + }); + } + + Future _submit() async { + final text = _ctrl.text.trim(); + if ((text.isEmpty && !_hasAttachment) || _sending) return; + + setState(() => _sending = true); + try { + String? resolvedUrl = _mediaUrl; + if (_mediaFile != null) { + setState(() => _uploading = true); + try { + resolvedUrl = await ImageUploadService().uploadImage(_mediaFile!); + } finally { + if (mounted) setState(() => _uploading = false); + } + } + await widget.onSend(text, resolvedUrl); + if (mounted) { + _ctrl.clear(); + _clearAttachment(); + } + } catch (_) { + // caller handles error display; don't clear on failure + } finally { + if (mounted) setState(() => _sending = false); + } + } + + @override + Widget build(BuildContext context) { + final busy = _sending || _uploading; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Text field + send button ───────────────────────────────────── + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _ctrl, + focusNode: widget.focusNode, + style: TextStyle(color: SojornColors.postContent, fontSize: 14), + maxLines: widget.config.maxLines, + decoration: InputDecoration( + hintText: widget.config.hintText, + hintStyle: TextStyle(color: SojornColors.textDisabled), + filled: true, + fillColor: AppTheme.scaffoldBg, + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + onSubmitted: (_) => _submit(), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: busy ? null : _submit, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: busy + ? AppTheme.brightNavy.withValues(alpha: 0.5) + : AppTheme.brightNavy, + ), + child: busy + ? const Padding( + padding: EdgeInsets.all(9), + child: CircularProgressIndicator( + strokeWidth: 2, + color: SojornColors.basicWhite, + ), + ) + : const Icon(Icons.send, + color: SojornColors.basicWhite, size: 16), + ), + ), + ], + ), + + // ── Media action row ───────────────────────────────────────────── + if (widget.config.hasMedia) ...[ + const SizedBox(height: 8), + Row( + children: [ + if (widget.config.allowImages) ...[ + _MediaPill( + icon: Icons.image_outlined, + label: 'Photo', + onTap: _pickImage, + ), + if (widget.config.allowGifs) const SizedBox(width: 8), + ], + if (widget.config.allowGifs) + _MediaPill( + icon: Icons.gif_outlined, + label: 'GIF', + onTap: () => + showGifPicker(context, onSelected: _attachGif), + ), + if (_hasAttachment) ...[ + const Spacer(), + GestureDetector( + onTap: _clearAttachment, + child: Icon(Icons.cancel_outlined, + size: 18, color: AppTheme.textSecondary), + ), + ], + ], + ), + // ── Attachment preview ─────────────────────────────────────── + if (_mediaFile != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.file(_mediaFile!, height: 120, fit: BoxFit.cover), + ), + ), + if (_mediaUrl != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + ApiConfig.needsProxy(_mediaUrl!) + ? ApiConfig.proxyImageUrl(_mediaUrl!) + : _mediaUrl!, + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + ), + ], + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// _MediaPill — small rounded pill button for Photo / GIF +// ───────────────────────────────────────────────────────────────────────────── + +class _MediaPill extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + const _MediaPill({required this.icon, required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/sojorn_app/lib/widgets/gif/gif_picker.dart b/sojorn_app/lib/widgets/gif/gif_picker.dart index 857d53c..60c90a8 100644 --- a/sojorn_app/lib/widgets/gif/gif_picker.dart +++ b/sojorn_app/lib/widgets/gif/gif_picker.dart @@ -239,7 +239,15 @@ class _MemeTabState extends State<_MemeTab> .cast>() .where((m) { final url = m['url'] as String? ?? ''; - return url.endsWith('.gif') && m['nsfw'] != true; + // Accept GIF-capable image URLs; reject video-only hosts and .mp4 + final isImage = !url.startsWith('https://v.redd.it/') && + !url.endsWith('.mp4') && + (url.endsWith('.gif') || + url.startsWith('https://i.redd.it/') || + url.startsWith('https://preview.redd.it/') || + url.startsWith('https://i.imgur.com/') || + url.startsWith('https://media.giphy.com/')); + return isImage && m['nsfw'] != true; }) .map((m) => _GifItem( url: m['url'] as String, @@ -255,7 +263,7 @@ class _MemeTabState extends State<_MemeTab> children: [ _SearchBar( ctrl: widget.searchCtrl, - hint: 'Search by subreddit (e.g. dogs, gaming)…', + hint: 'Search GIFs…', ), Expanded(child: _GifGrid( gifs: _gifs, diff --git a/sojorn_app/lib/widgets/traditional_quips_sheet.dart b/sojorn_app/lib/widgets/traditional_quips_sheet.dart index ece8f74..73c6f54 100644 --- a/sojorn_app/lib/widgets/traditional_quips_sheet.dart +++ b/sojorn_app/lib/widgets/traditional_quips_sheet.dart @@ -16,6 +16,7 @@ import '../theme/tokens.dart'; import '../widgets/media/signed_media_image.dart'; import '../widgets/reactions/reactions_display.dart'; import '../widgets/reactions/reaction_picker.dart'; +import '../widgets/composer/composer_bar.dart'; import '../widgets/modals/sanctuary_sheet.dart'; import '../widgets/sojorn_snackbar.dart'; import '../providers/notification_provider.dart'; @@ -51,7 +52,6 @@ class _TraditionalQuipsSheetState extends ConsumerState { final TextEditingController _commentController = TextEditingController(); final FocusNode _commentFocus = FocusNode(); - bool _isPosting = false; // Replying state ThreadNode? _replyingToNode; @@ -120,36 +120,18 @@ class _TraditionalQuipsSheetState extends ConsumerState { } } - Future _postComment() async { - final body = _commentController.text.trim(); - if (body.isEmpty) return; - - setState(() => _isPosting = true); - try { - final api = ref.read(apiServiceProvider); - await api.publishPost( - body: body, - chainParentId: _replyingToNode?.post.id ?? widget.postId, - allowChain: true, - ); - - _commentController.clear(); - _commentFocus.unfocus(); - setState(() => _replyingToNode = null); - - await _loadData(); - widget.onQuipPosted?.call(); - - if (mounted) { - sojornSnackbar.showSuccess(context: context, message: 'Comment posted!'); - } - } catch (e) { - if (mounted) { - sojornSnackbar.showError(context: context, message: 'Failed to post: $e'); - } - } finally { - if (mounted) setState(() => _isPosting = false); - } + Future _postComment(String body, String? _) async { + final api = ref.read(apiServiceProvider); + await api.publishPost( + body: body, + chainParentId: _replyingToNode?.post.id ?? widget.postId, + allowChain: true, + ); + _commentFocus.unfocus(); + if (mounted) setState(() => _replyingToNode = null); + await _loadData(); + widget.onQuipPosted?.call(); + if (mounted) sojornSnackbar.showSuccess(context: context, message: 'Comment posted!'); } void _startReply(ThreadNode node) { @@ -529,50 +511,11 @@ class _TraditionalQuipsSheetState extends ConsumerState { color: AppTheme.cardSurface, border: Border(top: BorderSide(color: AppTheme.egyptianBlue.withValues(alpha: 0.1))), ), - child: Row( - children: [ - Expanded( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: AppTheme.scaffoldBg, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppTheme.egyptianBlue.withValues(alpha: 0.2)), - ), - child: TextField( - controller: _commentController, - focusNode: _commentFocus, - style: TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - hintText: _replyingToNode != null ? 'Type your reply...' : 'Add a comment...', - hintStyle: TextStyle(color: AppTheme.textSecondary.withValues(alpha: 0.5)), - border: InputBorder.none, - ), - ), - ), - ), - const SizedBox(width: 12), - GestureDetector( - onTap: _isPosting ? null : () => _postComment(), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.brightNavy, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: AppTheme.brightNavy.withValues(alpha: 0.2), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], - ), - child: _isPosting - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite)) - : const Icon(Icons.send, color: SojornColors.basicWhite, size: 20), - ), - ), - ], + child: ComposerBar( + config: ComposerConfig.comment, + onSend: _postComment, + externalController: _commentController, + focusNode: _commentFocus, ), ), ],