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:
parent
7937e1c71a
commit
8844a95f3f
|
|
@ -285,6 +285,9 @@ func main() {
|
||||||
auth.POST("/reset-password", authHandler.ResetPassword)
|
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 := v1.Group("")
|
||||||
authorized.Use(middleware.AuthMiddleware(cfg.JWTSecret, dbPool))
|
authorized.Use(middleware.AuthMiddleware(cfg.JWTSecret, dbPool))
|
||||||
{
|
{
|
||||||
|
|
@ -404,8 +407,6 @@ func main() {
|
||||||
// Media routes
|
// Media routes
|
||||||
authorized.POST("/upload", mediaHandler.Upload)
|
authorized.POST("/upload", mediaHandler.Upload)
|
||||||
authorized.GET("/media/sign", mediaHandler.GetSignedMediaURL)
|
authorized.GET("/media/sign", mediaHandler.GetSignedMediaURL)
|
||||||
authorized.GET("/image-proxy", mediaHandler.ImageProxy)
|
|
||||||
|
|
||||||
// Search & Discover routes
|
// Search & Discover routes
|
||||||
discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService)
|
discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService)
|
||||||
authorized.GET("/search", discoverHandler.Search)
|
authorized.GET("/search", discoverHandler.Search)
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,8 @@ func (h *MediaHandler) ImageProxy(c *gin.Context) {
|
||||||
"https://preview.redd.it/",
|
"https://preview.redd.it/",
|
||||||
"https://external-preview.redd.it/",
|
"https://external-preview.redd.it/",
|
||||||
"https://blob.gifcities.org/gifcities/",
|
"https://blob.gifcities.org/gifcities/",
|
||||||
|
"https://i.imgur.com/",
|
||||||
|
"https://media.giphy.com/",
|
||||||
} {
|
} {
|
||||||
if strings.HasPrefix(rawURL, prefix) {
|
if strings.HasPrefix(rawURL, prefix) {
|
||||||
allowed = true
|
allowed = true
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ class ApiConfig {
|
||||||
return url.startsWith('https://i.redd.it/') ||
|
return url.startsWith('https://i.redd.it/') ||
|
||||||
url.startsWith('https://preview.redd.it/') ||
|
url.startsWith('https://preview.redd.it/') ||
|
||||||
url.startsWith('https://external-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/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import '../../services/capsule_security_service.dart';
|
||||||
import '../../services/content_guard_service.dart';
|
import '../../services/content_guard_service.dart';
|
||||||
import '../../theme/tokens.dart';
|
import '../../theme/tokens.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../widgets/gif/gif_picker.dart';
|
import '../../widgets/composer/composer_bar.dart';
|
||||||
|
|
||||||
class GroupChatTab extends StatefulWidget {
|
class GroupChatTab extends StatefulWidget {
|
||||||
final String groupId;
|
final String groupId;
|
||||||
|
|
@ -28,12 +28,9 @@ class GroupChatTab extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GroupChatTabState extends State<GroupChatTab> {
|
class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
final TextEditingController _msgCtrl = TextEditingController();
|
|
||||||
final ScrollController _scrollCtrl = ScrollController();
|
final ScrollController _scrollCtrl = ScrollController();
|
||||||
List<Map<String, dynamic>> _messages = [];
|
List<Map<String, dynamic>> _messages = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _sending = false;
|
|
||||||
String? _pendingGif; // GIF URL staged before send
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -43,7 +40,6 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_msgCtrl.dispose();
|
|
||||||
_scrollCtrl.dispose();
|
_scrollCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -103,12 +99,7 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
_messages = decrypted.reversed.toList();
|
_messages = decrypted.reversed.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _onChatSend(String text, String? gifUrl) async {
|
||||||
final text = _msgCtrl.text.trim();
|
|
||||||
final gif = _pendingGif;
|
|
||||||
if (text.isEmpty && gif == null) return;
|
|
||||||
if (_sending) return;
|
|
||||||
|
|
||||||
if (text.isNotEmpty) {
|
if (text.isNotEmpty) {
|
||||||
// Local content guard — block before encryption
|
// Local content guard — block before encryption
|
||||||
final guardReason = ContentGuardService.instance.check(text);
|
final guardReason = ContentGuardService.instance.check(text);
|
||||||
|
|
@ -118,7 +109,7 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
SnackBar(content: Text(guardReason), backgroundColor: Colors.red),
|
SnackBar(content: Text(guardReason), backgroundColor: Colors.red),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
throw Exception('blocked'); // prevents ComposerBar from clearing
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-side AI moderation — stateless, nothing stored
|
// Server-side AI moderation — stateless, nothing stored
|
||||||
|
|
@ -129,16 +120,14 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
SnackBar(content: Text(aiReason), backgroundColor: Colors.red),
|
SnackBar(content: Text(aiReason), backgroundColor: Colors.red),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
throw Exception('blocked');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _sending = true);
|
|
||||||
try {
|
|
||||||
final payload = {
|
final payload = {
|
||||||
'text': text,
|
'text': text,
|
||||||
'ts': DateTime.now().toIso8601String(),
|
'ts': DateTime.now().toIso8601String(),
|
||||||
if (gif != null) 'gif_url': gif,
|
if (gifUrl != null) 'gif_url': gifUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (widget.isEncrypted && widget.capsuleKey != null) {
|
if (widget.isEncrypted && widget.capsuleKey != null) {
|
||||||
|
|
@ -157,18 +146,10 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await ApiService.instance.sendGroupMessage(widget.groupId,
|
await ApiService.instance.sendGroupMessage(
|
||||||
body: text.isNotEmpty ? text : gif ?? '');
|
widget.groupId, body: text.isNotEmpty ? text : gifUrl ?? '');
|
||||||
}
|
}
|
||||||
_msgCtrl.clear();
|
if (mounted) await _loadMessages();
|
||||||
setState(() => _pendingGif = null);
|
|
||||||
await _loadMessages();
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mounted) setState(() => _sending = false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reportMessage(Map<String, dynamic> msg) {
|
void _reportMessage(Map<String, dynamic> msg) {
|
||||||
|
|
@ -341,92 +322,11 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Column(
|
child: ComposerBar(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
config: widget.isEncrypted
|
||||||
mainAxisSize: MainAxisSize.min,
|
? const ComposerConfig(hintText: 'Encrypted message…')
|
||||||
children: [
|
: ComposerConfig.chat,
|
||||||
// GIF preview
|
onSend: _onChatSend,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import '../../config/api_config.dart';
|
import '../../config/api_config.dart';
|
||||||
import '../../services/api_service.dart';
|
import '../../services/api_service.dart';
|
||||||
import '../../services/capsule_security_service.dart';
|
import '../../services/capsule_security_service.dart';
|
||||||
import '../../services/image_upload_service.dart';
|
|
||||||
import '../../theme/tokens.dart';
|
import '../../theme/tokens.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../widgets/gif/gif_picker.dart';
|
import '../../widgets/composer/composer_bar.dart';
|
||||||
|
|
||||||
class GroupFeedTab extends StatefulWidget {
|
class GroupFeedTab extends StatefulWidget {
|
||||||
final String groupId;
|
final String groupId;
|
||||||
|
|
@ -29,15 +26,8 @@ class GroupFeedTab extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GroupFeedTabState extends State<GroupFeedTab> {
|
class _GroupFeedTabState extends State<GroupFeedTab> {
|
||||||
final TextEditingController _postCtrl = TextEditingController();
|
|
||||||
List<Map<String, dynamic>> _posts = [];
|
List<Map<String, dynamic>> _posts = [];
|
||||||
bool _loading = true;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -47,39 +37,9 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_postCtrl.dispose();
|
|
||||||
super.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 {
|
Future<void> _loadPosts() async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -136,12 +96,7 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
||||||
_posts = decrypted;
|
_posts = decrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _createPost() async {
|
Future<void> _onComposerSend(String text, String? mediaUrl) 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) {
|
if (widget.isEncrypted && widget.capsuleKey != null) {
|
||||||
final encrypted = await CapsuleSecurityService.encryptPayload(
|
final encrypted = await CapsuleSecurityService.encryptPayload(
|
||||||
payload: {'text': text, 'ts': DateTime.now().toIso8601String()},
|
payload: {'text': text, 'ts': DateTime.now().toIso8601String()},
|
||||||
|
|
@ -158,22 +113,13 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final imageUrl = await _resolveImageUrl();
|
|
||||||
await ApiService.instance.createGroupPost(
|
await ApiService.instance.createGroupPost(
|
||||||
widget.groupId,
|
widget.groupId,
|
||||||
body: text,
|
body: text,
|
||||||
imageUrl: imageUrl,
|
imageUrl: mediaUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_postCtrl.clear();
|
if (mounted) await _loadPosts();
|
||||||
_clearAttachment();
|
|
||||||
await _loadPosts();
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to post: $e')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mounted) setState(() => _posting = false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLike(String postId, int index) async {
|
Future<void> _toggleLike(String postId, int index) async {
|
||||||
|
|
@ -214,99 +160,11 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
||||||
color: AppTheme.cardSurface,
|
color: AppTheme.cardSurface,
|
||||||
border: Border(bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.06))),
|
border: Border(bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.06))),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: ComposerBar(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
config: widget.isEncrypted
|
||||||
children: [
|
? ComposerConfig.privatePost
|
||||||
Row(
|
: ComposerConfig.publicPost,
|
||||||
children: [
|
onSend: _onComposerSend,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Posts list
|
// Posts list
|
||||||
|
|
@ -485,10 +343,8 @@ class _CommentsSheet extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CommentsSheetState extends State<_CommentsSheet> {
|
class _CommentsSheetState extends State<_CommentsSheet> {
|
||||||
final _commentCtrl = TextEditingController();
|
|
||||||
List<Map<String, dynamic>> _comments = [];
|
List<Map<String, dynamic>> _comments = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _sending = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -496,9 +352,6 @@ class _CommentsSheetState extends State<_CommentsSheet> {
|
||||||
_loadComments();
|
_loadComments();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() { _commentCtrl.dispose(); super.dispose(); }
|
|
||||||
|
|
||||||
Future<void> _loadComments() async {
|
Future<void> _loadComments() async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -507,16 +360,9 @@ class _CommentsSheetState extends State<_CommentsSheet> {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendComment() async {
|
Future<void> _sendComment(String text, String? _) 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);
|
await ApiService.instance.createGroupPostComment(widget.groupId, widget.postId, body: text);
|
||||||
_commentCtrl.clear();
|
|
||||||
await _loadComments();
|
await _loadComments();
|
||||||
} catch (_) {}
|
|
||||||
if (mounted) setState(() => _sending = false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -567,34 +413,9 @@ class _CommentsSheetState extends State<_CommentsSheet> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
ComposerBar(
|
||||||
children: [
|
config: ComposerConfig.comment,
|
||||||
Expanded(
|
onSend: _sendComment,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:cryptography/cryptography.dart';
|
||||||
import '../../services/api_service.dart';
|
import '../../services/api_service.dart';
|
||||||
import '../../theme/tokens.dart';
|
import '../../theme/tokens.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
import '../../widgets/composer/composer_bar.dart';
|
||||||
|
|
||||||
/// Thread detail screen with replies — works for both public and encrypted groups.
|
/// 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,
|
/// 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> {
|
class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
||||||
final _replyCtrl = TextEditingController();
|
|
||||||
Map<String, dynamic>? _thread;
|
Map<String, dynamic>? _thread;
|
||||||
List<Map<String, dynamic>> _replies = [];
|
List<Map<String, dynamic>> _replies = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _sending = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -40,12 +39,6 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
||||||
_loadThread();
|
_loadThread();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_replyCtrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadThread() async {
|
Future<void> _loadThread() async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -65,20 +58,9 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendReply() async {
|
Future<void> _sendReply(String text, String? _) 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);
|
await ApiService.instance.createGroupThreadReply(widget.groupId, widget.threadId, body: text);
|
||||||
_replyCtrl.clear();
|
|
||||||
await _loadThread();
|
await _loadThread();
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mounted) setState(() => _sending = false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int _uniqueParticipants() {
|
int _uniqueParticipants() {
|
||||||
|
|
@ -217,34 +199,9 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
||||||
color: AppTheme.cardSurface,
|
color: AppTheme.cardSurface,
|
||||||
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
|
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: ComposerBar(
|
||||||
children: [
|
config: ComposerConfig.threadReply,
|
||||||
Expanded(
|
onSend: _sendReply,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
312
sojorn_app/lib/widgets/composer/composer_bar.dart
Normal file
312
sojorn_app/lib/widgets/composer/composer_bar.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -239,7 +239,15 @@ class _MemeTabState extends State<_MemeTab>
|
||||||
.cast<Map<String, dynamic>>()
|
.cast<Map<String, dynamic>>()
|
||||||
.where((m) {
|
.where((m) {
|
||||||
final url = m['url'] as String? ?? '';
|
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(
|
.map((m) => _GifItem(
|
||||||
url: m['url'] as String,
|
url: m['url'] as String,
|
||||||
|
|
@ -255,7 +263,7 @@ class _MemeTabState extends State<_MemeTab>
|
||||||
children: [
|
children: [
|
||||||
_SearchBar(
|
_SearchBar(
|
||||||
ctrl: widget.searchCtrl,
|
ctrl: widget.searchCtrl,
|
||||||
hint: 'Search by subreddit (e.g. dogs, gaming)…',
|
hint: 'Search GIFs…',
|
||||||
),
|
),
|
||||||
Expanded(child: _GifGrid(
|
Expanded(child: _GifGrid(
|
||||||
gifs: _gifs,
|
gifs: _gifs,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import '../theme/tokens.dart';
|
||||||
import '../widgets/media/signed_media_image.dart';
|
import '../widgets/media/signed_media_image.dart';
|
||||||
import '../widgets/reactions/reactions_display.dart';
|
import '../widgets/reactions/reactions_display.dart';
|
||||||
import '../widgets/reactions/reaction_picker.dart';
|
import '../widgets/reactions/reaction_picker.dart';
|
||||||
|
import '../widgets/composer/composer_bar.dart';
|
||||||
import '../widgets/modals/sanctuary_sheet.dart';
|
import '../widgets/modals/sanctuary_sheet.dart';
|
||||||
import '../widgets/sojorn_snackbar.dart';
|
import '../widgets/sojorn_snackbar.dart';
|
||||||
import '../providers/notification_provider.dart';
|
import '../providers/notification_provider.dart';
|
||||||
|
|
@ -51,7 +52,6 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
|
||||||
|
|
||||||
final TextEditingController _commentController = TextEditingController();
|
final TextEditingController _commentController = TextEditingController();
|
||||||
final FocusNode _commentFocus = FocusNode();
|
final FocusNode _commentFocus = FocusNode();
|
||||||
bool _isPosting = false;
|
|
||||||
|
|
||||||
// Replying state
|
// Replying state
|
||||||
ThreadNode? _replyingToNode;
|
ThreadNode? _replyingToNode;
|
||||||
|
|
@ -120,36 +120,18 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _postComment() async {
|
Future<void> _postComment(String body, String? _) async {
|
||||||
final body = _commentController.text.trim();
|
|
||||||
if (body.isEmpty) return;
|
|
||||||
|
|
||||||
setState(() => _isPosting = true);
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
await api.publishPost(
|
await api.publishPost(
|
||||||
body: body,
|
body: body,
|
||||||
chainParentId: _replyingToNode?.post.id ?? widget.postId,
|
chainParentId: _replyingToNode?.post.id ?? widget.postId,
|
||||||
allowChain: true,
|
allowChain: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
_commentController.clear();
|
|
||||||
_commentFocus.unfocus();
|
_commentFocus.unfocus();
|
||||||
setState(() => _replyingToNode = null);
|
if (mounted) setState(() => _replyingToNode = null);
|
||||||
|
|
||||||
await _loadData();
|
await _loadData();
|
||||||
widget.onQuipPosted?.call();
|
widget.onQuipPosted?.call();
|
||||||
|
if (mounted) sojornSnackbar.showSuccess(context: context, message: 'Comment posted!');
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startReply(ThreadNode node) {
|
void _startReply(ThreadNode node) {
|
||||||
|
|
@ -529,50 +511,11 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
|
||||||
color: AppTheme.cardSurface,
|
color: AppTheme.cardSurface,
|
||||||
border: Border(top: BorderSide(color: AppTheme.egyptianBlue.withValues(alpha: 0.1))),
|
border: Border(top: BorderSide(color: AppTheme.egyptianBlue.withValues(alpha: 0.1))),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: ComposerBar(
|
||||||
children: [
|
config: ComposerConfig.comment,
|
||||||
Expanded(
|
onSend: _postComment,
|
||||||
child: Container(
|
externalController: _commentController,
|
||||||
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,
|
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue