feat: Central ComposerBar widget + fix GIF proxy 401 + broaden GIF filter

- Move /image-proxy to public (unauthenticated) route so CachedNetworkImage
  can fetch proxied GIFs without Bearer token (fixes 401 errors)
- Expand image proxy SSRF allowlist to include i.imgur.com + media.giphy.com
- Broaden GIF filter in gif_picker: accept i.redd.it, preview.redd.it,
  i.imgur.com URLs (not just .gif extension) so Reddit posts actually show
- Change GIF search hint to 'Search GIFs…' (no subreddit mention)
- Add i.imgur.com + media.giphy.com to client-side needsProxy() list
- Create lib/widgets/composer/composer_bar.dart — single ComposerBar widget
  with ComposerConfig presets (publicPost/comment/threadReply/chat/privatePost)
  that handles text, GIF picker, image picker, upload, and send button uniformly
- Apply ComposerBar to: group feed posts, group feed comments sheet,
  group thread reply bar, quips comment sheet, group chat bar
- Remove all duplicate TextField+send-button+media-button implementations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Britton 2026-02-18 09:45:58 -06:00
parent 7937e1c71a
commit 8844a95f3f
9 changed files with 423 additions and 509 deletions

View file

@ -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)

View file

@ -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

View file

@ -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/');
}
}

View file

@ -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<GroupChatTab> {
final TextEditingController _msgCtrl = TextEditingController();
final ScrollController _scrollCtrl = ScrollController();
List<Map<String, dynamic>> _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<GroupChatTab> {
@override
void dispose() {
_msgCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
@ -103,12 +99,7 @@ class _GroupChatTabState extends State<GroupChatTab> {
_messages = decrypted.reversed.toList();
}
Future<void> _sendMessage() async {
final text = _msgCtrl.text.trim();
final gif = _pendingGif;
if (text.isEmpty && gif == null) return;
if (_sending) return;
Future<void> _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<GroupChatTab> {
SnackBar(content: Text(guardReason), backgroundColor: Colors.red),
);
}
return;
throw Exception('blocked'); // prevents ComposerBar from clearing
}
// Server-side AI moderation stateless, nothing stored
@ -129,16 +120,14 @@ class _GroupChatTabState extends State<GroupChatTab> {
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,
if (gifUrl != null) 'gif_url': gifUrl,
};
if (widget.isEncrypted && widget.capsuleKey != null) {
@ -157,18 +146,10 @@ class _GroupChatTabState extends State<GroupChatTab> {
},
);
} else {
await ApiService.instance.sendGroupMessage(widget.groupId,
body: text.isNotEmpty ? text : gif ?? '');
await ApiService.instance.sendGroupMessage(
widget.groupId, body: text.isNotEmpty ? text : gifUrl ?? '');
}
_msgCtrl.clear();
setState(() => _pendingGif = null);
await _loadMessages();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e')));
}
}
if (mounted) setState(() => _sending = false);
if (mounted) await _loadMessages();
}
void _reportMessage(Map<String, dynamic> msg) {
@ -341,92 +322,11 @@ class _GroupChatTabState extends State<GroupChatTab> {
),
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,
),
),
),

View file

@ -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<GroupFeedTab> {
final TextEditingController _postCtrl = TextEditingController();
List<Map<String, dynamic>> _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<GroupFeedTab> {
@override
void dispose() {
_postCtrl.dispose();
super.dispose();
}
Future<void> _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<String?> _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<void> _loadPosts() async {
setState(() => _loading = true);
try {
@ -136,12 +96,7 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
_posts = decrypted;
}
Future<void> _createPost() async {
final text = _postCtrl.text.trim();
final hasAttachment = _pickedImage != null || _pendingImageUrl != null;
if ((text.isEmpty && !hasAttachment) || _posting) return;
setState(() => _posting = true);
try {
Future<void> _onComposerSend(String text, String? mediaUrl) async {
if (widget.isEncrypted && widget.capsuleKey != null) {
final encrypted = await CapsuleSecurityService.encryptPayload(
payload: {'text': text, 'ts': DateTime.now().toIso8601String()},
@ -158,22 +113,13 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
},
);
} else {
final imageUrl = await _resolveImageUrl();
await ApiService.instance.createGroupPost(
widget.groupId,
body: text,
imageUrl: imageUrl,
imageUrl: mediaUrl,
);
}
_postCtrl.clear();
_clearAttachment();
await _loadPosts();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to post: $e')));
}
}
if (mounted) setState(() => _posting = false);
if (mounted) await _loadPosts();
}
Future<void> _toggleLike(String postId, int index) async {
@ -214,99 +160,11 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
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<Map<String, dynamic>> _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<void> _loadComments() async {
setState(() => _loading = true);
try {
@ -507,16 +360,9 @@ class _CommentsSheetState extends State<_CommentsSheet> {
if (mounted) setState(() => _loading = false);
}
Future<void> _sendComment() async {
final text = _commentCtrl.text.trim();
if (text.isEmpty || _sending) return;
setState(() => _sending = true);
try {
Future<void> _sendComment(String text, String? _) async {
await ApiService.instance.createGroupPostComment(widget.groupId, widget.postId, body: text);
_commentCtrl.clear();
await _loadComments();
} catch (_) {}
if (mounted) setState(() => _sending = false);
}
@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)),
],
),
),
);
}
}

View file

@ -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<GroupThreadDetailScreen> {
final _replyCtrl = TextEditingController();
Map<String, dynamic>? _thread;
List<Map<String, dynamic>> _replies = [];
bool _loading = true;
bool _sending = false;
@override
void initState() {
@ -40,12 +39,6 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
_loadThread();
}
@override
void dispose() {
_replyCtrl.dispose();
super.dispose();
}
Future<void> _loadThread() async {
setState(() => _loading = true);
try {
@ -65,20 +58,9 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
if (mounted) setState(() => _loading = false);
}
Future<void> _sendReply() async {
final text = _replyCtrl.text.trim();
if (text.isEmpty || _sending || widget.isEncrypted) return;
setState(() => _sending = true);
try {
Future<void> _sendReply(String text, String? _) async {
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);
}
int _uniqueParticipants() {
@ -217,34 +199,9 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
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,
),
),
],

View file

@ -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<void> 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<ComposerBar> createState() => _ComposerBarState();
}
class _ComposerBarState extends State<ComposerBar> {
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<void> _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<void> _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,
),
),
],
),
),
);
}
}

View file

@ -239,7 +239,15 @@ class _MemeTabState extends State<_MemeTab>
.cast<Map<String, dynamic>>()
.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,

View file

@ -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<TraditionalQuipsSheet> {
final TextEditingController _commentController = TextEditingController();
final FocusNode _commentFocus = FocusNode();
bool _isPosting = false;
// Replying state
ThreadNode? _replyingToNode;
@ -120,36 +120,18 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
}
}
Future<void> _postComment() async {
final body = _commentController.text.trim();
if (body.isEmpty) return;
setState(() => _isPosting = true);
try {
Future<void> _postComment(String body, String? _) async {
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);
if (mounted) 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);
}
if (mounted) sojornSnackbar.showSuccess(context: context, message: 'Comment posted!');
}
void _startReply(ThreadNode node) {
@ -529,50 +511,11 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
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,
child: ComposerBar(
config: ComposerConfig.comment,
onSend: _postComment,
externalController: _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),
),
),
],
),
),
],