feat: Reaction system for Quips feed + fix groups 500 + reduce jank

- Replace heart/like in Quips sidebar with full reaction system:
  tap = quick ❤️, long-press = full ReactionPicker dialog
- Add reactionPackageProvider (CDN → local assets → emoji fallback)
- Switch ReactionPicker to ConsumerStatefulWidget using provider
- Add CachedNetworkImage support in ReactionPicker + _ReactionIcon
- Fix CreateGroup handler: use 'privacy' column, drop non-existent
  'is_private'/'banner_url' columns (were causing 500 on group creation)
- Cache overlayJson parsing in QuipVideoItem initState/didUpdateWidget
  to eliminate double jsonDecode per build frame (was causing 174ms jank)
- Add post_hides table + HidePost handler + feed filtering
- Add showNavActions param to TraditionalQuipsSheet for clean Quips header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Britton 2026-02-18 08:11:08 -06:00
parent 5b5e89e383
commit 93a2c45a92
13 changed files with 1206 additions and 668 deletions

View file

@ -343,6 +343,7 @@ func main() {
authorized.DELETE("/posts/:id", postHandler.DeletePost) authorized.DELETE("/posts/:id", postHandler.DeletePost)
authorized.POST("/posts/:id/pin", postHandler.PinPost) authorized.POST("/posts/:id/pin", postHandler.PinPost)
authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility) authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility)
authorized.POST("/posts/:id/hide", postHandler.HidePost)
authorized.POST("/posts/:id/like", postHandler.LikePost) authorized.POST("/posts/:id/like", postHandler.LikePost)
authorized.DELETE("/posts/:id/like", postHandler.UnlikePost) authorized.DELETE("/posts/:id/like", postHandler.UnlikePost)
authorized.POST("/posts/:id/save", postHandler.SavePost) authorized.POST("/posts/:id/save", postHandler.SavePost)

View file

@ -218,7 +218,6 @@ func (h *GroupsHandler) CreateGroup(c *gin.Context) {
Category string `json:"category" binding:"required"` Category string `json:"category" binding:"required"`
IsPrivate bool `json:"is_private"` IsPrivate bool `json:"is_private"`
AvatarURL *string `json:"avatar_url"` AvatarURL *string `json:"avatar_url"`
BannerURL *string `json:"banner_url"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -229,6 +228,11 @@ func (h *GroupsHandler) CreateGroup(c *gin.Context) {
// Normalize name for uniqueness check // Normalize name for uniqueness check
req.Name = strings.TrimSpace(req.Name) req.Name = strings.TrimSpace(req.Name)
privacy := "public"
if req.IsPrivate {
privacy = "private"
}
tx, err := h.db.Begin(c.Request.Context()) tx, err := h.db.Begin(c.Request.Context())
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"})
@ -239,10 +243,10 @@ func (h *GroupsHandler) CreateGroup(c *gin.Context) {
// Create group // Create group
var groupID string var groupID string
err = tx.QueryRow(c.Request.Context(), ` err = tx.QueryRow(c.Request.Context(), `
INSERT INTO groups (name, description, category, is_private, created_by, avatar_url, banner_url) INSERT INTO groups (name, description, category, privacy, created_by, avatar_url)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id RETURNING id
`, req.Name, req.Description, req.Category, req.IsPrivate, userID, req.AvatarURL, req.BannerURL).Scan(&groupID) `, req.Name, req.Description, req.Category, privacy, userID, req.AvatarURL).Scan(&groupID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") { if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {

View file

@ -1170,6 +1170,22 @@ func (h *PostHandler) UnlikePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Post unliked"}) c.JSON(http.StatusOK, gin.H{"message": "Post unliked"})
} }
// HidePost records a "Not Interested" signal for a post.
// The post will be excluded from all subsequent feed queries for this user,
// and repeated hides of the same author trigger algorithmic suppression.
func (h *PostHandler) HidePost(c *gin.Context) {
postID := c.Param("id")
userIDStr, _ := c.Get("user_id")
err := h.postRepo.HidePost(c.Request.Context(), postID, userIDStr.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hide post", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Post hidden"})
}
func (h *PostHandler) SavePost(c *gin.Context) { func (h *PostHandler) SavePost(c *gin.Context) {
postID := c.Param("id") postID := c.Param("id")
userIDStr, _ := c.Get("user_id") userIDStr, _ := c.Get("user_id")

View file

@ -186,6 +186,10 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
) )
) )
AND NOT public.has_block_between(p.author_id, CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END) AND NOT public.has_block_between(p.author_id, CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END)
AND ($4::text = '' OR NOT EXISTS (
SELECT 1 FROM public.post_hides ph
WHERE ph.post_id = p.id AND ph.user_id = $4::text::uuid
))
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4'))) AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4')))
AND ($5 = '' OR c.slug = $5) AND ($5 = '' OR c.slug = $5)
AND ( AND (
@ -497,6 +501,18 @@ func (r *PostRepository) UnlikePost(ctx context.Context, postID string, userID s
return err return err
} }
// HidePost records a "Not Interested" signal.
// Denormalises author_id so feeds can suppress prolific-hide authors without a JOIN.
func (r *PostRepository) HidePost(ctx context.Context, postID, userID string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO public.post_hides (user_id, post_id, author_id)
SELECT $2::uuid, $1::uuid, author_id
FROM public.posts WHERE id = $1::uuid
ON CONFLICT (user_id, post_id) DO NOTHING
`, postID, userID)
return err
}
func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error { func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
query := ` query := `
WITH inserted AS ( WITH inserted AS (

View file

@ -465,6 +465,11 @@ func (s *FeedAlgorithmService) GetAlgorithmicFeed(ctx context.Context, viewerID
LEFT JOIN user_feed_impressions ufi LEFT JOIN user_feed_impressions ufi
ON ufi.post_id = pfs.post_id AND ufi.user_id = $1 ON ufi.post_id = pfs.post_id AND ufi.user_id = $1
WHERE p.status = 'active' WHERE p.status = 'active'
AND pfs.post_id NOT IN (SELECT post_id FROM public.post_hides WHERE user_id = $1::uuid)
AND p.user_id NOT IN (
SELECT author_id FROM public.post_hides
WHERE user_id = $1::uuid GROUP BY author_id HAVING COUNT(*) >= 2
)
` `
personalArgs := []interface{}{viewerID} personalArgs := []interface{}{viewerID}
argIdx := 2 argIdx := 2
@ -567,6 +572,11 @@ func (s *FeedAlgorithmService) GetAlgorithmicFeed(ctx context.Context, viewerID
JOIN post_feed_scores pfs ON pfs.post_id = p.id JOIN post_feed_scores pfs ON pfs.post_id = p.id
WHERE p.status = 'active' WHERE p.status = 'active'
AND p.category NOT IN (%s) AND p.category NOT IN (%s)
AND p.id NOT IN (SELECT post_id FROM public.post_hides WHERE user_id = $1)
AND p.user_id NOT IN (
SELECT author_id FROM public.post_hides
WHERE user_id = $1 GROUP BY author_id HAVING COUNT(*) >= 2
)
ORDER BY random() ORDER BY random()
LIMIT $2 LIMIT $2
`, placeholders) `, placeholders)

View file

@ -0,0 +1,134 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
const _cdnBase = 'https://reactions.sojorn.net';
/// Parsed reaction package ready for use by [ReactionPicker].
class ReactionPackage {
final List<String> tabOrder;
final Map<String, List<String>> reactionSets; // tabId list of identifiers (URL or emoji)
final Map<String, String> folderCredits; // tabId credit markdown
const ReactionPackage({
required this.tabOrder,
required this.reactionSets,
required this.folderCredits,
});
}
/// Riverpod provider that loads reaction sets once per app session.
/// Priority: CDN index.json local assets hardcoded emoji.
final reactionPackageProvider = FutureProvider<ReactionPackage>((ref) async {
// 1. Try CDN
try {
final response = await http
.get(Uri.parse('$_cdnBase/index.json'))
.timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final tabsRaw =
(data['tabs'] as List? ?? []).whereType<Map<String, dynamic>>();
final tabOrder = <String>['emoji'];
final reactionSets = <String, List<String>>{'emoji': _defaultEmoji};
final folderCredits = <String, String>{};
for (final tab in tabsRaw) {
final id = tab['id'] as String? ?? '';
if (id.isEmpty || id == 'emoji') continue;
final credit = tab['credit'] as String?;
final files =
(tab['reactions'] as List? ?? []).whereType<String>().toList();
final urls = files.map((f) => '$_cdnBase/$id/$f').toList();
tabOrder.add(id);
reactionSets[id] = urls;
if (credit != null && credit.isNotEmpty) {
folderCredits[id] = credit;
}
}
// Only return CDN result if we got actual image tabs (not just emoji)
if (tabOrder.length > 1) {
return ReactionPackage(
tabOrder: tabOrder,
reactionSets: reactionSets,
folderCredits: folderCredits,
);
}
}
} catch (_) {}
// 2. Fallback: local assets
try {
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final assetPaths = manifest.listAssets();
final reactionAssets = assetPaths.where((path) {
final lp = path.toLowerCase();
return lp.startsWith('assets/reactions/') &&
(lp.endsWith('.png') ||
lp.endsWith('.svg') ||
lp.endsWith('.webp') ||
lp.endsWith('.jpg') ||
lp.endsWith('.jpeg') ||
lp.endsWith('.gif'));
}).toList();
if (reactionAssets.isNotEmpty) {
final tabOrder = <String>['emoji'];
final reactionSets = <String, List<String>>{'emoji': _defaultEmoji};
final folderCredits = <String, String>{};
for (final path in reactionAssets) {
final parts = path.split('/');
if (parts.length >= 4) {
final folder = parts[2];
if (!reactionSets.containsKey(folder)) {
tabOrder.add(folder);
reactionSets[folder] = [];
try {
final creditPath = 'assets/reactions/$folder/credit.md';
if (assetPaths.contains(creditPath)) {
folderCredits[folder] =
await rootBundle.loadString(creditPath);
}
} catch (_) {}
}
reactionSets[folder]!.add(path);
}
}
for (final key in reactionSets.keys) {
if (key != 'emoji') {
reactionSets[key]!
.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
}
}
return ReactionPackage(
tabOrder: tabOrder,
reactionSets: reactionSets,
folderCredits: folderCredits,
);
}
} catch (_) {}
// 3. Hardcoded emoji fallback
return ReactionPackage(
tabOrder: ['emoji'],
reactionSets: {'emoji': _defaultEmoji},
folderCredits: {},
);
});
const _defaultEmoji = [
'❤️', '👍', '😂', '😮', '😢', '😡',
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
'😍', '🤣', '😊', '👌', '🙌', '💪',
'🎯', '', '', '🌟', '💫', '☀️',
];

View file

@ -1,69 +1,172 @@
import 'dart:convert'; import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../../../models/quip_text_overlay.dart'; import '../../../models/quip_text_overlay.dart';
import '../../../widgets/media/signed_media_image.dart'; import '../../../widgets/media/signed_media_image.dart';
import '../../../widgets/video_player_with_comments.dart';
import '../../../models/post.dart';
import '../../../models/profile.dart';
import '../../../theme/tokens.dart'; import '../../../theme/tokens.dart';
import 'quips_feed_screen.dart'; import 'quips_feed_screen.dart';
class QuipVideoItem extends StatelessWidget { class QuipVideoItem extends StatefulWidget {
final Quip quip; final Quip quip;
final VideoPlayerController? controller; final VideoPlayerController? controller;
final bool isActive; final bool isActive;
final bool isLiked; final Map<String, int> reactions;
final int likeCount; final Set<String> myReactions;
final int commentCount;
final bool isUserPaused; final bool isUserPaused;
final VoidCallback onLike; final Function(String emoji) onReact;
final VoidCallback onOpenReactionPicker;
final VoidCallback onComment; final VoidCallback onComment;
final VoidCallback onShare; final VoidCallback onShare;
final VoidCallback onTogglePause; final VoidCallback onTogglePause;
final VoidCallback onNotInterested;
const QuipVideoItem({ const QuipVideoItem({
super.key, super.key,
required this.quip, required this.quip,
required this.controller, required this.controller,
required this.isActive, required this.isActive,
required this.isLiked, this.reactions = const {},
required this.likeCount, this.myReactions = const {},
this.commentCount = 0,
required this.isUserPaused, required this.isUserPaused,
required this.onLike, required this.onReact,
required this.onOpenReactionPicker,
required this.onComment, required this.onComment,
required this.onShare, required this.onShare,
required this.onTogglePause, required this.onTogglePause,
required this.onNotInterested,
}); });
/// Convert Quip to Post for use with VideoPlayerWithComments @override
Post _toPost() { State<QuipVideoItem> createState() => _QuipVideoItemState();
return Post( }
id: quip.id,
authorId: quip.username, // This would need to be the actual user ID class _QuipVideoItemState extends State<QuipVideoItem>
body: quip.caption, with SingleTickerProviderStateMixin {
status: PostStatus.active, late final AnimationController _heartController;
detectedTone: ToneLabel.neutral, late final Animation<double> _heartScale;
contentIntegrityScore: 0.8, late final Animation<double> _heartOpacity;
createdAt: DateTime.now(), // Would need actual timestamp Offset _heartPosition = Offset.zero;
videoUrl: quip.videoUrl, bool _showHeart = false;
thumbnailUrl: quip.thumbnailUrl, bool _isCaptionExpanded = false;
likeCount: likeCount,
commentCount: 0, // Would need to be fetched separately // Cached overlay data parsed once, not on every build
author: Profile( late String _audioLabel;
id: quip.username, late List<QuipOverlayItem> _overlayItems;
handle: quip.username,
displayName: quip.displayName ?? '', static const _quickReactEmoji = '❤️';
createdAt: DateTime.now(),
@override
void initState() {
super.initState();
_cacheOverlayData();
_heartController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_heartScale = TweenSequence([
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.4), weight: 35),
TweenSequenceItem(tween: Tween(begin: 1.4, end: 1.0), weight: 25),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 20),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 20),
]).animate(_heartController);
_heartOpacity = TweenSequence([
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 10),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 60),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30),
]).animate(_heartController);
}
@override
void didUpdateWidget(QuipVideoItem oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.quip.overlayJson != widget.quip.overlayJson) {
_cacheOverlayData();
}
}
void _cacheOverlayData() {
_audioLabel = _computeAudioLabel();
_overlayItems = _parseOverlayItems();
}
String _computeAudioLabel() {
final json = widget.quip.overlayJson;
if (json != null && json.isNotEmpty) {
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
final soundId = decoded['sound_id'];
if (soundId is String && soundId.isNotEmpty) {
return soundId.split('/').last.split('.').first;
}
} catch (_) {}
}
return 'Original Sound';
}
List<QuipOverlayItem> _parseOverlayItems() {
final json = widget.quip.overlayJson;
if (json == null || json.isEmpty) return [];
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
return (decoded['overlays'] as List<dynamic>? ?? [])
.whereType<Map<String, dynamic>>()
.map(QuipOverlayItem.fromJson)
.toList();
} catch (_) {
return [];
}
}
@override
void dispose() {
_heartController.dispose();
super.dispose();
}
void _handleDoubleTap(TapDownDetails details) {
// Double-tap quick-reacts with (only if not already reacted)
if (!widget.myReactions.contains(_quickReactEmoji)) {
widget.onReact(_quickReactEmoji);
}
setState(() {
_heartPosition = details.localPosition;
_showHeart = true;
});
_heartController.forward(from: 0).then((_) {
if (mounted) setState(() => _showHeart = false);
});
}
void _navigateToProfile() {
context.push('/u/${widget.quip.username}');
}
void _showMoreSheet() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (_) => _MoreOptionsSheet(
quipId: widget.quip.id,
onNotInterested: widget.onNotInterested,
), ),
); );
} }
// Audio label is cached in _audioLabel do not call jsonDecode here
Widget _buildVideo() { Widget _buildVideo() {
final initialized = controller?.value.isInitialized ?? false; final ctrl = widget.controller;
final initialized = ctrl?.value.isInitialized ?? false;
if (initialized) { if (initialized) {
final size = controller!.value.size; final size = ctrl!.value.size;
return Container( return Container(
color: SojornColors.basicBlack, color: SojornColors.basicBlack,
child: Center( child: Center(
@ -72,124 +175,344 @@ class QuipVideoItem extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: size.width, width: size.width,
height: size.height, height: size.height,
child: VideoPlayer(controller!), child: VideoPlayer(ctrl),
), ),
), ),
), ),
); );
} }
if (quip.thumbnailUrl.isNotEmpty) { if (widget.quip.thumbnailUrl.isNotEmpty) {
return SignedMediaImage( return SignedMediaImage(
url: quip.thumbnailUrl, url: widget.quip.thumbnailUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: SojornColors.basicBlack), errorBuilder: (_, __, ___) => Container(color: SojornColors.basicBlack),
loadingBuilder: (context) { loadingBuilder: (_) => Container(color: SojornColors.basicBlack),
return Container(
color: SojornColors.basicBlack,
child: const Center(
child: CircularProgressIndicator(color: SojornColors.basicWhite),
),
);
},
); );
} }
return Container(color: SojornColors.basicBlack); return Container(color: SojornColors.basicBlack);
} }
Widget _buildActions() { Widget _buildProgressBar() {
final actions = [ final ctrl = widget.controller;
_QuipAction( if (ctrl == null || !ctrl.value.isInitialized) return const SizedBox.shrink();
icon: isLiked ? Icons.favorite : Icons.favorite_border, return Positioned(
label: likeCount > 0 ? likeCount.toString() : '', left: 0,
onTap: onLike, right: 0,
color: isLiked ? SojornColors.destructive : SojornColors.basicWhite, bottom: 0,
child: VideoProgressIndicator(
ctrl,
allowScrubbing: false,
padding: EdgeInsets.zero,
colors: const VideoProgressColors(
playedColor: Color(0xCCFFFFFF),
bufferedColor: Color(0x44FFFFFF),
backgroundColor: Color(0x22FFFFFF),
), ),
_QuipAction(
icon: Icons.chat_bubble_outline,
onTap: onComment,
), ),
_QuipAction( );
icon: Icons.send_outlined, }
onTap: onShare,
Widget _buildAvatar() {
final avatarUrl = widget.quip.avatarUrl;
final letter = widget.quip.username.isNotEmpty
? widget.quip.username[0].toUpperCase()
: '?';
Widget inner;
if (avatarUrl != null && avatarUrl.isNotEmpty) {
inner = SignedMediaImage(
url: avatarUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _fallbackAvatarInner(letter),
loadingBuilder: (_) => _fallbackAvatarInner(letter),
);
} else {
inner = _fallbackAvatarInner(letter);
}
return GestureDetector(
onTap: _navigateToProfile,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: SojornColors.basicWhite, width: 2),
), ),
_QuipAction( child: ClipOval(child: inner),
icon: Icons.more_horiz,
onTap: () {},
), ),
]; // "+" badge
Positioned(
bottom: -4,
left: 0,
right: 0,
child: Center(
child: Container(
width: 20,
height: 20,
decoration: const BoxDecoration(
color: Color(0xFF2979FF),
shape: BoxShape.circle,
),
child: const Icon(Icons.add, color: Colors.white, size: 14),
),
),
),
],
),
);
}
Widget _fallbackAvatarInner(String letter) {
return Container(
color: const Color(0xFF2A2A2A),
child: Center(
child: Text(
letter,
style: const TextStyle(
color: SojornColors.basicWhite,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
);
}
Widget _buildSideActions() {
final commentLabel =
widget.commentCount > 0 ? _formatCount(widget.commentCount) : null;
final totalReactions =
widget.reactions.values.fold(0, (sum, c) => sum + c);
final reactionLabel =
totalReactions > 0 ? _formatCount(totalReactions) : null;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: actions children: [
.map((action) => Padding( _buildAvatar(),
padding: const EdgeInsets.symmetric(vertical: 10), const SizedBox(height: 24),
child: action, // Reaction button tap to quick-react , long-press to open full picker
)) _buildActionBtn(
.toList(), child: _buildReactionIcon(),
onTap: () => widget.onReact(_quickReactEmoji),
onLongPress: widget.onOpenReactionPicker,
label: reactionLabel,
),
const SizedBox(height: 20),
// Comment
_buildActionBtn(
child: const Icon(Icons.chat_bubble_outline,
color: SojornColors.basicWhite, size: 28),
onTap: widget.onComment,
label: commentLabel,
),
const SizedBox(height: 20),
// Share
_buildActionBtn(
child: const Icon(Icons.send_outlined,
color: SojornColors.basicWhite, size: 28),
onTap: widget.onShare,
),
const SizedBox(height: 20),
// More (three dots)
_buildActionBtn(
child: const Icon(Icons.more_horiz,
color: SojornColors.basicWhite, size: 28),
onTap: _showMoreSheet,
),
],
); );
} }
Widget _buildOverlay() { /// Shows the user's own top reaction, the post's top reaction, or a generic
/// add-reaction icon in the right sidebar.
Widget _buildReactionIcon() {
String? reactionId;
if (widget.myReactions.isNotEmpty) {
reactionId = widget.myReactions.first;
} else if (widget.reactions.isNotEmpty) {
reactionId = widget.reactions.entries
.reduce((a, b) => a.value > b.value ? a : b)
.key;
}
if (reactionId == null) {
return const Icon(Icons.add_reaction_outlined,
color: SojornColors.basicWhite, size: 30);
}
// Emoji
if (!reactionId.startsWith('https://') &&
!reactionId.startsWith('assets/') &&
!reactionId.startsWith('asset:')) {
return Text(reactionId, style: const TextStyle(fontSize: 30));
}
// CDN URL
if (reactionId.startsWith('https://')) {
return CachedNetworkImage(
imageUrl: reactionId,
width: 30,
height: 30,
fit: BoxFit.contain,
placeholder: (_, __) => const Icon(Icons.add_reaction_outlined,
color: SojornColors.basicWhite, size: 30),
errorWidget: (_, __, ___) => const Icon(Icons.add_reaction_outlined,
color: SojornColors.basicWhite, size: 30),
);
}
// Local asset
final assetPath = reactionId.startsWith('asset:')
? reactionId.replaceFirst('asset:', '')
: reactionId;
if (assetPath.endsWith('.svg')) {
return SvgPicture.asset(assetPath,
width: 30,
height: 30,
colorFilter: const ColorFilter.mode(
SojornColors.basicWhite, BlendMode.srcIn));
}
return Image.asset(assetPath, width: 30, height: 30, fit: BoxFit.contain);
}
Widget _buildActionBtn({
required Widget child,
required VoidCallback onTap,
VoidCallback? onLongPress,
String? label,
}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
behavior: HitTestBehavior.opaque,
child: Padding(padding: const EdgeInsets.all(4), child: child),
),
if (label != null) ...[
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
color: SojornColors.basicWhite,
fontSize: 12,
fontWeight: FontWeight.w600,
shadows: [
Shadow(
color: Color(0x8A000000),
blurRadius: 4,
offset: Offset(0, 1),
),
],
),
),
],
],
);
}
Widget _buildUserInfo() {
final audioLabel =
'\u266b $_audioLabel \u2022 @${widget.quip.username}';
final hasCaption = widget.quip.caption.isNotEmpty;
return Positioned( return Positioned(
left: 16, left: 16,
right: 16, right: 80,
bottom: 28, bottom: 28,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( // Username
'@${quip.username}', GestureDetector(
onTap: _navigateToProfile,
child: Text(
'@${widget.quip.username}',
style: const TextStyle( style: const TextStyle(
color: SojornColors.basicWhite, color: SojornColors.basicWhite,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 16, fontSize: 15,
shadows: [ shadows: [
Shadow( Shadow(
color: const Color(0x8A000000), color: Color(0x8A000000),
offset: Offset(0, 1), offset: Offset(0, 1),
blurRadius: 6, blurRadius: 6,
), ),
], ],
), ),
), ),
const SizedBox(height: 6), ),
Text( // Caption with "...more" expand
quip.caption, if (hasCaption) ...[
maxLines: 2, const SizedBox(height: 4),
GestureDetector(
onTap: () => setState(
() => _isCaptionExpanded = !_isCaptionExpanded),
child: RichText(
text: TextSpan(
style: const TextStyle(
color: SojornColors.basicWhite,
fontSize: 13,
shadows: [
Shadow(
color: Color(0x8A000000),
offset: Offset(0, 1),
blurRadius: 6,
),
],
),
children: [
TextSpan(text: widget.quip.caption),
if (!_isCaptionExpanded &&
widget.quip.caption.length > 60)
const TextSpan(
text: ' ...more',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xCCFFFFFF),
),
),
],
),
maxLines: _isCaptionExpanded ? null : 2,
overflow: _isCaptionExpanded
? TextOverflow.visible
: TextOverflow.ellipsis,
),
),
],
const SizedBox(height: 10),
// Audio ticker row
Row(
children: [
Flexible(
child: Text(
audioLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: SojornColors.basicWhite, color: SojornColors.basicWhite,
fontSize: 14, fontSize: 12,
shadows: [
Shadow(
color: const Color(0x8A000000),
offset: Offset(0, 1),
blurRadius: 6,
),
],
),
),
const SizedBox(height: 10),
Row(
children: const [
Icon(Icons.music_note, color: SojornColors.basicWhite, size: 18),
SizedBox(width: 6),
Text(
'Original Audio',
style: TextStyle(
color: SojornColors.basicWhite,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
shadows: [ shadows: [
Shadow( Shadow(
color: const Color(0x73000000), color: Color(0x73000000),
offset: Offset(0, 1), offset: Offset(0, 1),
blurRadius: 6, blurRadius: 4,
), ),
], ],
), ),
), ),
),
], ],
), ),
], ],
@ -197,30 +520,57 @@ class QuipVideoItem extends StatelessWidget {
); );
} }
/// Parses overlay_json and returns a list of non-interactive overlay widgets Widget _buildPauseOverlay() {
/// rendered on top of the video during feed playback. if (!widget.isActive || !widget.isUserPaused) return const SizedBox.shrink();
List<Widget> _buildOverlayWidgets(BoxConstraints constraints) { return Center(
final json = quip.overlayJson; child: Container(
if (json == null || json.isEmpty) return []; padding: const EdgeInsets.all(16),
try { decoration: const BoxDecoration(
final decoded = jsonDecode(json) as Map<String, dynamic>; color: Color(0x8A000000),
final items = (decoded['overlays'] as List<dynamic>? ?? []) shape: BoxShape.circle,
.whereType<Map<String, dynamic>>() ),
.map(QuipOverlayItem.fromJson) child: const Icon(
.toList(); Icons.play_arrow,
color: SojornColors.basicWhite,
size: 52,
),
),
);
}
Widget _buildHeartBurst() {
if (!_showHeart) return const SizedBox.shrink();
return Positioned(
left: _heartPosition.dx - 50,
top: _heartPosition.dy - 50,
child: IgnorePointer(
child: AnimatedBuilder(
animation: _heartController,
builder: (_, __) => Opacity(
opacity: _heartOpacity.value,
child: Transform.scale(
scale: _heartScale.value,
child: const Icon(Icons.favorite, color: Colors.white, size: 100),
),
),
),
),
);
}
List<Widget> _buildOverlayWidgets(BoxConstraints constraints) {
if (_overlayItems.isEmpty) return [];
final w = constraints.maxWidth; final w = constraints.maxWidth;
final h = constraints.maxHeight; final h = constraints.maxHeight;
return items.map((item) { return _overlayItems.map((item) {
final absX = item.position.dx * w; final absX = item.position.dx * w;
final absY = item.position.dy * h; final absY = item.position.dy * h;
final isSticker = item.type == QuipOverlayType.sticker; final isSticker = item.type == QuipOverlayType.sticker;
Widget child; Widget child;
if (isSticker) { if (isSticker) {
final isEmoji = item.content.runes.length == 1 || final isEmoji = item.content.runes.length == 1 || item.content.length <= 2;
item.content.length <= 2;
if (isEmoji) { if (isEmoji) {
child = Text(item.content, child = Text(item.content,
style: TextStyle(fontSize: 42 * item.scale)); style: TextStyle(fontSize: 42 * item.scale));
@ -267,120 +617,63 @@ class QuipVideoItem extends StatelessWidget {
child: Transform.rotate(angle: item.rotation, child: child), child: Transform.rotate(angle: item.rotation, child: child),
); );
}).toList(); }).toList();
} catch (_) {
return [];
}
} }
Widget _buildPauseOverlay() { String _formatCount(int n) {
if (!isActive || !isUserPaused) return const SizedBox.shrink(); if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M';
if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K';
return Center( return '$n';
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: SojornColors.overlayDark,
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow,
color: SojornColors.basicWhite,
size: 48,
),
),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: onTogglePause, onTap: widget.onTogglePause,
onDoubleTapDown: _handleDoubleTap,
onDoubleTap: () {}, // consume event so single-tap doesn't fire on double-tap
child: Container( child: Container(
color: SojornColors.basicBlack, color: SojornColors.basicBlack,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) => Stack( builder: (context, constraints) => Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
AnimatedOpacity( _buildVideo(),
duration: const Duration(milliseconds: 200),
opacity: isActive ? 1 : 0.6,
child: _buildVideo(),
),
// Quip overlays (text + stickers, non-interactive in feed) // Quip overlays (text + stickers, non-interactive in feed)
..._buildOverlayWidgets(constraints), ..._buildOverlayWidgets(constraints),
Container( // Gradient scrim: strong at bottom for text legibility
decoration: const BoxDecoration( const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.bottomCenter, begin: Alignment.topCenter,
end: Alignment.topCenter, end: Alignment.bottomCenter,
colors: [ colors: [
const Color(0x8A000000), Color(0x55000000), // subtle top vignette
SojornColors.transparent, Colors.transparent,
const Color(0x73000000), Colors.transparent,
Color(0xB0000000), // strong bottom scrim
], ],
stops: [0, 0.4, 1], stops: [0, 0.15, 0.55, 1],
), ),
), ),
), ),
_buildOverlay(), // User info bottom-left
_buildUserInfo(),
// Side actions right
Positioned( Positioned(
right: 16, right: 12,
bottom: 80, bottom: 90,
child: _buildActions(), child: _buildSideActions(),
),
Positioned(
top: 36,
left: 16,
child: Row(
children: [
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0x73000000),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: const Color(0x66000000),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: const Text(
'Quips',
style: TextStyle(
color: SojornColors.basicWhite,
fontWeight: FontWeight.bold,
),
),
),
if (quip.durationMs != null)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: const Color(0x73000000),
borderRadius: BorderRadius.circular(24),
),
child: Text(
'${(quip.durationMs! / 1000).toStringAsFixed(1)}s',
style: const TextStyle(
color: SojornColors.basicWhite,
fontSize: 12,
),
),
),
],
),
), ),
// Pause overlay
_buildPauseOverlay(), _buildPauseOverlay(),
if (!(controller?.value.isInitialized ?? false)) // Double-tap heart burst
Center( _buildHeartBurst(),
child: const CircularProgressIndicator(color: SojornColors.basicWhite), // Thin video progress bar at very bottom
_buildProgressBar(),
// Buffering spinner
if (!(widget.controller?.value.isInitialized ?? false))
const Center(
child: CircularProgressIndicator(color: SojornColors.basicWhite),
), ),
], ],
), ),
@ -388,73 +681,95 @@ class QuipVideoItem extends StatelessWidget {
), ),
); );
} }
/// Build the enhanced video player with comments (for fullscreen view)
Widget buildEnhancedVideoPlayer(BuildContext context) {
return VideoPlayerWithComments(
post: _toPost(),
onLike: onLike,
onShare: onShare,
onCommentTap: () {
// Comments are handled within the VideoPlayerWithComments widget
},
);
}
} }
class _QuipAction extends StatelessWidget { /// Bottom sheet shown when the user taps the "..." (more) button on a quip.
final IconData icon; class _MoreOptionsSheet extends StatelessWidget {
final String? label; final String quipId;
final VoidCallback onTap; final VoidCallback onNotInterested;
final Color color;
const _QuipAction({ const _MoreOptionsSheet({
required this.icon, required this.quipId,
required this.onTap, required this.onNotInterested,
this.label,
this.color = SojornColors.basicWhite,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Container(
decoration: const BoxDecoration(
color: Color(0xFF1A1A1A),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.fromLTRB(0, 8, 0, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
// Drag handle
Container( Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0x8A000000), color: Colors.white.withValues(alpha: 0.2),
shape: BoxShape.circle, borderRadius: BorderRadius.circular(2),
boxShadow: [ ),
BoxShadow( ),
color: const Color(0x66000000), _buildOption(
blurRadius: 8, context,
offset: const Offset(0, 2), icon: Icons.thumb_down_outlined,
label: 'Not Interested',
onTap: () {
Navigator.pop(context);
onNotInterested();
},
),
_buildOption(
context,
icon: Icons.flag_outlined,
label: 'Report',
color: const Color(0xFFFF5252),
onTap: () {
Navigator.pop(context);
// TODO: Wire to SanctuarySheet.showForPostId(context, quipId)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Report submitted. Thank you.'),
behavior: SnackBarBehavior.floating,
),
);
},
), ),
], ],
), ),
child: IconButton( );
onPressed: onTap, }
icon: Icon(icon, color: color),
), Widget _buildOption(
), BuildContext context, {
if (label != null && label!.isNotEmpty) ...[ required IconData icon,
const SizedBox(height: 4), required String label,
required VoidCallback onTap,
Color color = Colors.white,
}) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(width: 16),
Text( Text(
label!, label,
style: const TextStyle( style: TextStyle(
color: SojornColors.basicWhite, color: color,
fontSize: 12, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w500,
shadows: [ ),
Shadow(
color: const Color(0x8A000000),
blurRadius: 4,
offset: Offset(0, 1),
), ),
], ],
), ),
), ),
],
],
); );
} }
} }

View file

@ -9,9 +9,9 @@ import '../../../providers/feed_refresh_provider.dart';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import '../../../theme/app_theme.dart'; import '../../../theme/app_theme.dart';
import '../../../theme/tokens.dart'; import '../../../theme/tokens.dart';
import '../../post/post_detail_screen.dart';
import 'quip_video_item.dart'; import 'quip_video_item.dart';
import '../../home/home_shell.dart'; import '../../home/home_shell.dart';
import '../../../widgets/reactions/reaction_picker.dart';
import '../../../widgets/video_comments_sheet.dart'; import '../../../widgets/video_comments_sheet.dart';
class Quip { class Quip {
@ -23,8 +23,10 @@ class Quip {
final String? displayName; final String? displayName;
final String? avatarUrl; final String? avatarUrl;
final int? durationMs; final int? durationMs;
final int? likeCount; final int commentCount;
final String? overlayJson; final String? overlayJson;
final Map<String, int> reactions;
final Set<String> myReactions;
const Quip({ const Quip({
required this.id, required this.id,
@ -35,8 +37,10 @@ class Quip {
this.displayName, this.displayName,
this.avatarUrl, this.avatarUrl,
this.durationMs, this.durationMs,
this.likeCount, this.commentCount = 0,
this.overlayJson, this.overlayJson,
this.reactions = const {},
this.myReactions = const {},
}); });
factory Quip.fromMap(Map<String, dynamic> map) { factory Quip.fromMap(Map<String, dynamic> map) {
@ -54,18 +58,29 @@ class Quip {
displayName: author?['display_name'] as String?, displayName: author?['display_name'] as String?,
avatarUrl: author?['avatar_url'] as String?, avatarUrl: author?['avatar_url'] as String?,
durationMs: map['duration_ms'] as int?, durationMs: map['duration_ms'] as int?,
likeCount: _parseLikeCount(map['metrics']), commentCount: _parseCount(map['comment_count']),
overlayJson: map['overlay_json'] as String?, overlayJson: map['overlay_json'] as String?,
reactions: _parseReactions(map['reactions']),
myReactions: _parseMyReactions(map['my_reactions']),
); );
} }
static int? _parseLikeCount(dynamic metrics) { static Map<String, int> _parseReactions(dynamic v) {
if (metrics is Map<String, dynamic>) { if (v is Map<String, dynamic>) {
final val = metrics['like_count']; return v.map((k, val) => MapEntry(k, val is int ? val : (val is num ? val.toInt() : 0)));
if (val is int) return val;
if (val is num) return val.toInt();
} }
return null; return {};
}
static Set<String> _parseMyReactions(dynamic v) {
if (v is List) return v.whereType<String>().toSet();
return {};
}
static int _parseCount(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
return 0;
} }
} }
@ -86,8 +101,8 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
final List<Quip> _quips = []; final List<Quip> _quips = [];
final Map<int, VideoPlayerController> _controllers = {}; final Map<int, VideoPlayerController> _controllers = {};
final Map<int, Future<void>> _controllerFutures = {}; final Map<int, Future<void>> _controllerFutures = {};
final Map<String, bool> _liked = {}; final Map<String, Map<String, int>> _reactionCounts = {};
final Map<String, int> _likeCounts = {}; final Map<String, Set<String>> _myReactions = {};
bool _isLoading = false; bool _isLoading = false;
bool _hasMore = true; bool _hasMore = true;
@ -268,7 +283,8 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
} }
} else { } else {
} }
} catch (e) { } catch (_) {
// Ignore initial post will just not appear at top
} }
} }
} }
@ -297,7 +313,10 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
_quips.addAll(items); _quips.addAll(items);
_hasMore = items.length == _pageSize; _hasMore = items.length == _pageSize;
for (final item in items) { for (final item in items) {
_likeCounts.putIfAbsent(item.id, () => item.likeCount ?? 0); _reactionCounts.putIfAbsent(
item.id, () => Map<String, int>.from(item.reactions));
_myReactions.putIfAbsent(
item.id, () => Set<String>.from(item.myReactions));
} }
}); });
@ -409,41 +428,79 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
await _fetchQuips(); await _fetchQuips();
} }
Future<void> _toggleLike(Quip quip) async { Future<void> _toggleReaction(Quip quip, String emoji) async {
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
final currentlyLiked = _liked[quip.id] ?? false; final currentCounts =
Map<String, int>.from(_reactionCounts[quip.id] ?? quip.reactions);
final currentMine =
Set<String>.from(_myReactions[quip.id] ?? quip.myReactions);
// Optimistic update
final isRemoving = currentMine.contains(emoji);
setState(() { setState(() {
_liked[quip.id] = !currentlyLiked; if (isRemoving) {
final currentCount = _likeCounts[quip.id] ?? 0; currentMine.remove(emoji);
final next = currentlyLiked ? currentCount - 1 : currentCount + 1; final newCount = (currentCounts[emoji] ?? 1) - 1;
_likeCounts[quip.id] = next < 0 ? 0 : next; if (newCount <= 0) {
currentCounts.remove(emoji);
} else {
currentCounts[emoji] = newCount;
}
} else {
currentMine.add(emoji);
currentCounts[emoji] = (currentCounts[emoji] ?? 0) + 1;
}
_reactionCounts[quip.id] = currentCounts;
_myReactions[quip.id] = currentMine;
}); });
try { try {
if (currentlyLiked) { await api.toggleReaction(quip.id, emoji);
await api.unappreciatePost(quip.id);
} else {
await api.appreciatePost(quip.id);
}
} catch (_) { } catch (_) {
// revert on failure // Revert on failure
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_liked[quip.id] = currentlyLiked; _reactionCounts[quip.id] = Map<String, int>.from(quip.reactions);
_likeCounts[quip.id] = _myReactions[quip.id] = Set<String>.from(quip.myReactions);
(_likeCounts[quip.id] ?? 0) + (currentlyLiked ? 1 : -1);
if ((_likeCounts[quip.id] ?? 0) < 0) {
_likeCounts[quip.id] = 0;
}
}); });
if (mounted) { }
ScaffoldMessenger.of(context).showSnackBar( }
const SnackBar(
content: Text('Could not update like. Please try again.'), void _openReactionPicker(Quip quip) {
showDialog(
context: context,
builder: (_) => ReactionPicker(
onReactionSelected: (emoji) => _toggleReaction(quip, emoji),
reactionCounts: _reactionCounts[quip.id] ?? quip.reactions,
myReactions: _myReactions[quip.id] ?? quip.myReactions,
), ),
); );
} }
Future<void> _handleNotInterested(Quip quip) async {
final index = _quips.indexOf(quip);
if (index == -1) return;
// Optimistic removal user sees it gone immediately
setState(() {
_quips.removeAt(index);
final ctrl = _controllers.remove(index);
ctrl?.dispose();
// Remap controllers above the removed index
final remapped = <int, VideoPlayerController>{};
_controllers.forEach((k, v) {
remapped[k > index ? k - 1 : k] = v;
});
_controllers
..clear()
..addAll(remapped);
if (_currentIndex >= _quips.length && _currentIndex > 0) {
_currentIndex = _quips.length - 1;
} }
});
// Fire-and-forget to backend no revert on failure (signal still valuable)
ref.read(apiServiceProvider).hidePost(quip.id).catchError((_) {});
} }
Future<void> _openComments(Quip quip) async { Future<void> _openComments(Quip quip) async {
@ -453,10 +510,9 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
backgroundColor: SojornColors.transparent, backgroundColor: SojornColors.transparent,
builder: (context) => VideoCommentsSheet( builder: (context) => VideoCommentsSheet(
postId: quip.id, postId: quip.id,
initialCommentCount: 0, initialCommentCount: quip.commentCount,
onCommentPosted: () { showNavActions: false,
// Optional: handle reload if needed onCommentPosted: () {},
},
), ),
); );
} }
@ -528,8 +584,7 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
child: PageView.builder( child: PageView.builder(
controller: _pageController, controller: _pageController,
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
// Ensure physics allows scrolling to trigger refresh physics: const PageScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _quips.length, itemCount: _quips.length,
onPageChanged: (index) { onPageChanged: (index) {
_currentIndex = index; _currentIndex = index;
@ -542,8 +597,6 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
itemBuilder: (context, index) { itemBuilder: (context, index) {
final quip = _quips[index]; final quip = _quips[index];
final controller = _controllers[index]; final controller = _controllers[index];
final isLiked = _liked[quip.id] ?? false;
final likeCount = _likeCounts[quip.id] ?? quip.likeCount ?? 0;
return VisibilityDetector( return VisibilityDetector(
key: ValueKey('quip-${quip.id}'), key: ValueKey('quip-${quip.id}'),
onVisibilityChanged: (info) => onVisibilityChanged: (info) =>
@ -552,13 +605,16 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
quip: quip, quip: quip,
controller: controller, controller: controller,
isActive: index == _currentIndex, isActive: index == _currentIndex,
isLiked: isLiked, reactions: _reactionCounts[quip.id] ?? quip.reactions,
likeCount: likeCount, myReactions: _myReactions[quip.id] ?? quip.myReactions,
commentCount: quip.commentCount,
isUserPaused: _isUserPaused, isUserPaused: _isUserPaused,
onLike: () => _toggleLike(quip), onReact: (emoji) => _toggleReaction(quip, emoji),
onOpenReactionPicker: () => _openReactionPicker(quip),
onComment: () => _openComments(quip), onComment: () => _openComments(quip),
onShare: () => _shareQuip(quip), onShare: () => _shareQuip(quip),
onTogglePause: _toggleUserPause, onTogglePause: _toggleUserPause,
onNotInterested: () => _handleNotInterested(quip),
), ),
); );
}, },

View file

@ -1089,6 +1089,10 @@ class ApiService {
); );
} }
Future<void> hidePost(String postId) async {
await _callGoApi('/posts/$postId/hide', method: 'POST');
}
Future<void> appreciatePost(String postId) async { Future<void> appreciatePost(String postId) async {
await _callGoApi( await _callGoApi(
'/posts/$postId/like', '/posts/$postId/like',

View file

@ -1,15 +1,15 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'dart:convert'; import '../../providers/reactions_provider.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../theme/tokens.dart'; import '../../theme/tokens.dart';
class ReactionPicker extends StatefulWidget { class ReactionPicker extends ConsumerStatefulWidget {
final Function(String) onReactionSelected; final Function(String) onReactionSelected;
final VoidCallback? onClosed; final VoidCallback? onClosed;
final List<String>? reactions; final List<String>? reactions;
@ -26,134 +26,45 @@ class ReactionPicker extends StatefulWidget {
}); });
@override @override
State<ReactionPicker> createState() => _ReactionPickerState(); ConsumerState<ReactionPicker> createState() => _ReactionPickerState();
} }
class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProviderStateMixin { class _ReactionPickerState extends ConsumerState<ReactionPicker>
late TabController _tabController; with SingleTickerProviderStateMixin {
TabController? _tabController;
int _currentTabIndex = 0; int _currentTabIndex = 0;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
bool _isSearching = false; bool _isSearching = false;
List<String> _filteredReactions = []; List<String> _filteredReactions = [];
// Dynamic reaction sets
Map<String, List<String>> _reactionSets = {};
Map<String, String> _folderCredits = {};
List<String> _tabOrder = [];
bool _isLoading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchController.addListener(_onSearchChanged); _searchController.addListener(_onSearchChanged);
_loadReactionSets();
} }
@override
void dispose() {
Future<void> _loadReactionSets() async { _tabController?.dispose();
try { _searchController.dispose();
final reactionSets = <String, List<String>>{ super.dispose();
'emoji': [
'❤️', '👍', '😂', '😮', '😢', '😡',
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
'😍', '🤣', '😊', '👌', '🙌', '💪',
'🎯', '', '', '🌟', '💫', '☀️',
],
};
final folderCredits = <String, String>{};
final tabOrder = ['emoji'];
// Load the manifest to discover assets
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final assetPaths = manifest.listAssets();
// Filter for reaction assets
final reactionAssets = assetPaths.where((path) {
final lowerPath = path.toLowerCase();
return lowerPath.startsWith('assets/reactions/') &&
(lowerPath.endsWith('.png') ||
lowerPath.endsWith('.svg') ||
lowerPath.endsWith('.webp') ||
lowerPath.endsWith('.jpg') ||
lowerPath.endsWith('.jpeg') ||
lowerPath.endsWith('.gif'));
}).toList();
for (final path in reactionAssets) {
// Path format: assets/reactions/FOLDER_NAME/FILE_NAME.ext
final parts = path.split('/');
if (parts.length >= 4) {
final folderName = parts[2];
if (!reactionSets.containsKey(folderName)) {
reactionSets[folderName] = [];
tabOrder.add(folderName);
// Try to load credit file if it's the first time we see this folder
try {
final creditPath = 'assets/reactions/$folderName/credit.md';
// Check if credit file exists in manifest too
if (assetPaths.contains(creditPath)) {
final creditData = await rootBundle.loadString(creditPath);
folderCredits[folderName] = creditData;
}
} catch (e) {
// Ignore missing credit files
}
} }
reactionSets[folderName]!.add(path); void _ensureTabController(ReactionPackage package) {
final neededLength = package.tabOrder.length;
if (_tabController != null && _tabController!.length == neededLength) {
return;
} }
} _tabController?.dispose();
_tabController = TabController(length: neededLength, vsync: this);
// Sort reactions within each set by file name _tabController!.addListener(() {
for (final key in reactionSets.keys) {
if (key != 'emoji') {
reactionSets[key]!.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
}
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_reactionSets = reactionSets; _currentTabIndex = _tabController!.index;
_folderCredits = folderCredits;
_tabOrder = tabOrder;
_isLoading = false;
_tabController = TabController(length: _tabOrder.length, vsync: this);
_tabController.addListener(() {
if (mounted) {
setState(() {
_currentTabIndex = _tabController.index;
_clearSearch(); _clearSearch();
}); });
} }
}); });
});
}
} catch (e) {
// Fallback
if (mounted) {
setState(() {
_reactionSets = {
'emoji': ['❤️', '👍', '😂', '😮', '😢', '😡']
};
_tabOrder = ['emoji'];
_isLoading = false;
_tabController = TabController(length: 1, vsync: this);
});
}
}
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
} }
void _clearSearch() { void _clearSearch() {
@ -180,29 +91,41 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
} }
List<String> _filterReactions(String query) { List<String> _filterReactions(String query) {
final reactions = _currentReactions; final reactions = _filterCurrentTab();
return reactions.where((reaction) { return reactions.where((reaction) {
// For image reactions, search by filename if (reaction.startsWith('assets/reactions/') ||
if (reaction.startsWith('assets/reactions/')) { reaction.startsWith('https://')) {
final fileName = reaction.split('/').last.toLowerCase(); final fileName = reaction.split('/').last.toLowerCase();
return fileName.contains(query); return fileName.contains(query);
} }
// For emoji, search by description (you could add a mapping)
return reaction.toLowerCase().contains(query); return reaction.toLowerCase().contains(query);
}).toList(); }).toList();
} }
List<String> get _currentReactions { List<String> _filterCurrentTab() {
if (_tabOrder.isEmpty || _currentTabIndex >= _tabOrder.length) { final package = ref.read(reactionPackageProvider).value;
return []; if (package == null) return [];
} final tabOrder = package.tabOrder;
final currentTab = _tabOrder[_currentTabIndex]; if (_currentTabIndex >= tabOrder.length) return [];
return _reactionSets[currentTab] ?? []; return package.reactionSets[tabOrder[_currentTabIndex]] ?? [];
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isLoading) { final packageAsync = ref.watch(reactionPackageProvider);
return packageAsync.when(
loading: () => _buildLoadingDialog(),
error: (_, __) => _buildLoadingDialog(),
data: (package) {
_ensureTabController(package);
if (_tabController == null) return _buildLoadingDialog();
return _buildPicker(package);
},
);
}
Widget _buildLoadingDialog() {
return Dialog( return Dialog(
backgroundColor: SojornColors.transparent, backgroundColor: SojornColors.transparent,
child: Container( child: Container(
@ -211,16 +134,18 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.cardSurface, color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)), border:
), Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
child: const Center(
child: CircularProgressIndicator(),
), ),
child: const Center(child: CircularProgressIndicator()),
), ),
); );
} }
final reactions = widget.reactions ?? (_isSearching ? _filteredReactions : _currentReactions); Widget _buildPicker(ReactionPackage package) {
final tabOrder = package.tabOrder;
final reactionSets = package.reactionSets;
final reactionCounts = widget.reactionCounts ?? {}; final reactionCounts = widget.reactionCounts ?? {};
final myReactions = widget.myReactions ?? {}; final myReactions = widget.myReactions ?? {};
@ -247,8 +172,6 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Header with search // Header with search
Column(
children: [
Row( Row(
children: [ children: [
Text( Text(
@ -308,9 +231,7 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
size: 18, size: 18,
), ),
onPressed: () { onPressed: () => _searchController.clear(),
_searchController.clear();
},
) )
: null, : null,
border: InputBorder.none, border: InputBorder.none,
@ -321,8 +242,6 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
), ),
), ),
), ),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Tabs // Tabs
@ -333,7 +252,7 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: TabBar( child: TabBar(
controller: _tabController, controller: _tabController!,
onTap: (index) { onTap: (index) {
setState(() { setState(() {
_currentTabIndex = index; _currentTabIndex = index;
@ -353,16 +272,14 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
indicatorSize: TabBarIndicatorSize.tab, indicatorSize: TabBarIndicatorSize.tab,
tabs: _tabOrder.map((tabName) { tabs: tabOrder
return Tab( .map((name) => Tab(text: name.toUpperCase()))
text: tabName.toUpperCase(), .toList(),
);
}).toList(),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Search results info // No results message
if (_isSearching && _filteredReactions.isEmpty) if (_isSearching && _filteredReactions.isEmpty)
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@ -382,25 +299,28 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
// Reaction grid // Reaction grid
SizedBox( SizedBox(
height: 420, // Increased height to show more rows at once height: 420,
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController!,
children: _tabOrder.map((tabName) { children: tabOrder.map((tabName) {
final reactions = _reactionSets[tabName] ?? []; final tabReactions = reactionSets[tabName] ?? [];
final isEmoji = tabName == 'emoji'; final isEmoji = tabName == 'emoji';
final credit = _folderCredits[tabName]; final credit = package.folderCredits[tabName];
return Column( return Column(
children: [ children: [
// Reaction grid
Expanded( Expanded(
child: _buildReactionGrid(reactions, widget.reactionCounts ?? {}, widget.myReactions ?? {}, !isEmoji), child: _buildReactionGrid(
_isSearching ? _filteredReactions : tabReactions,
reactionCounts,
myReactions,
!isEmoji,
),
), ),
// Credit section (only for non-emoji tabs)
if (credit != null && credit.isNotEmpty) if (credit != null && credit.isNotEmpty)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -415,7 +335,6 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// Parse and display credit markdown
_buildCreditDisplay(credit), _buildCreditDisplay(credit),
], ],
), ),
@ -437,18 +356,32 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
data: credit, data: credit,
selectable: true, selectable: true,
onTapLink: (text, href, title) { onTapLink: (text, href, title) {
if (href != null) { if (href != null) launchUrl(Uri.parse(href));
launchUrl(Uri.parse(href));
}
}, },
styleSheet: MarkdownStyleSheet( styleSheet: MarkdownStyleSheet(
p: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary), p: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
h1: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), h1: GoogleFonts.inter(
h2: GoogleFonts.inter(fontSize: 11, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), fontSize: 12,
listBullet: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary), fontWeight: FontWeight.bold,
strong: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), color: AppTheme.textPrimary),
em: GoogleFonts.inter(fontSize: 10, fontStyle: FontStyle.italic, color: AppTheme.textPrimary), h2: GoogleFonts.inter(
a: GoogleFonts.inter(fontSize: 10, color: AppTheme.brightNavy, decoration: TextDecoration.underline), fontSize: 11,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary),
listBullet:
GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary),
strong: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary),
em: GoogleFonts.inter(
fontSize: 10,
fontStyle: FontStyle.italic,
color: AppTheme.textPrimary),
a: GoogleFonts.inter(
fontSize: 10,
color: AppTheme.brightNavy,
decoration: TextDecoration.underline),
), ),
); );
} }
@ -479,9 +412,9 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
child: InkWell( child: InkWell(
onTap: () { onTap: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
final result = reaction.startsWith('assets/') // CDN URLs and emoji are passed as-is; local assets get 'asset:' prefix
? 'asset:$reaction' final result =
: reaction; reaction.startsWith('assets/') ? 'asset:$reaction' : reaction;
widget.onReactionSelected(result); widget.onReactionSelected(result);
}, },
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -510,7 +443,8 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
right: 2, right: 2,
bottom: 2, bottom: 2,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.brightNavy, color: AppTheme.brightNavy,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@ -535,13 +469,27 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
} }
Widget _buildEmojiReaction(String emoji) { Widget _buildEmojiReaction(String emoji) {
return Text( return Text(emoji, style: const TextStyle(fontSize: 24));
emoji,
style: const TextStyle(fontSize: 24),
);
} }
Widget _buildImageReaction(String reaction) { Widget _buildImageReaction(String reaction) {
// CDN URL
if (reaction.startsWith('https://')) {
return CachedNetworkImage(
imageUrl: reaction,
width: 32,
height: 32,
fit: BoxFit.contain,
placeholder: (_, __) => const SizedBox(width: 32, height: 32),
errorWidget: (_, __, ___) => Icon(
Icons.image_not_supported,
size: 24,
color: AppTheme.textSecondary,
),
);
}
// Local asset (with or without 'asset:' prefix)
final imagePath = reaction.startsWith('asset:') final imagePath = reaction.startsWith('asset:')
? reaction.replaceFirst('asset:', '') ? reaction.replaceFirst('asset:', '')
: reaction; : reaction;
@ -560,13 +508,11 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
), ),
), ),
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) => Icon(
return Icon(
Icons.image_not_supported, Icons.image_not_supported,
size: 24, size: 24,
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
); ),
},
); );
} }
@ -574,13 +520,11 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
imagePath, imagePath,
width: 32, width: 32,
height: 32, height: 32,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) => Icon(
return Icon(
Icons.image_not_supported, Icons.image_not_supported,
size: 24, size: 24,
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
); ),
},
); );
} }
} }

View file

@ -1,3 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
@ -245,17 +246,27 @@ class _ReactionIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// CDN URL
if (reactionId.startsWith('https://')) {
return CachedNetworkImage(
imageUrl: reactionId,
width: size,
height: size,
fit: BoxFit.contain,
placeholder: (_, __) => SizedBox(width: size, height: size),
errorWidget: (_, __, ___) =>
Icon(Icons.image_not_supported, size: size * 0.8),
);
}
// Local asset
if (reactionId.startsWith('assets/') || reactionId.startsWith('asset:')) { if (reactionId.startsWith('assets/') || reactionId.startsWith('asset:')) {
final assetPath = reactionId.startsWith('asset:') final assetPath = reactionId.startsWith('asset:')
? reactionId.replaceFirst('asset:', '') ? reactionId.replaceFirst('asset:', '')
: reactionId; : reactionId;
if (assetPath.endsWith('.svg')) { if (assetPath.endsWith('.svg')) {
return SvgPicture.asset( return SvgPicture.asset(assetPath, width: size, height: size);
assetPath,
width: size,
height: size,
);
} }
return Image.asset( return Image.asset(
assetPath, assetPath,
@ -264,9 +275,8 @@ class _ReactionIcon extends StatelessWidget {
fit: BoxFit.contain, fit: BoxFit.contain,
); );
} }
return Text(
reactionId, // Emoji
style: TextStyle(fontSize: size), return Text(reactionId, style: TextStyle(fontSize: size));
);
} }
} }

View file

@ -26,12 +26,16 @@ class TraditionalQuipsSheet extends ConsumerStatefulWidget {
final String postId; final String postId;
final int initialQuipCount; final int initialQuipCount;
final VoidCallback? onQuipPosted; final VoidCallback? onQuipPosted;
/// When false (e.g. Quips video feed), shows only "X Comments" + close button
/// with no Home/Chat/Search navigation icons.
final bool showNavActions;
const TraditionalQuipsSheet({ const TraditionalQuipsSheet({
super.key, super.key,
required this.postId, required this.postId,
this.initialQuipCount = 0, this.initialQuipCount = 0,
this.onQuipPosted, this.onQuipPosted,
this.showNavActions = true,
}); });
@override @override
@ -301,7 +305,7 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: AppTheme.egyptianBlue.withValues(alpha: 0.1), color: AppTheme.egyptianBlue.withValues(alpha: 0.1),
width: 1 width: 1,
), ),
), ),
), ),
@ -321,44 +325,7 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Row( child: Row(
children: [ children: [
if (!_isSelectionMode) ...[ if (_isSelectionMode) ...[
IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
),
const SizedBox(width: 4),
Text(
'Thread',
style: GoogleFonts.inter(
fontWeight: FontWeight.w700,
fontSize: 18,
color: AppTheme.textPrimary,
),
),
const Spacer(),
IconButton(
onPressed: () => context.go(AppRoutes.homeAlias),
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () {}, // Search - to be implemented or consistent with ThreadedConversationScreen
icon: Icon(Icons.search, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () => context.go(AppRoutes.secureChat),
icon: Consumer(
builder: (context, ref, child) {
final badge = ref.watch(currentBadgeProvider);
return Badge(
label: Text(badge.messageCount.toString()),
isLabelVisible: badge.messageCount > 0,
backgroundColor: AppTheme.brightNavy,
child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
);
},
),
),
] else ...[
IconButton( IconButton(
onPressed: () => setState(() { onPressed: () => setState(() {
_isSelectionMode = false; _isSelectionMode = false;
@ -380,7 +347,64 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
icon: const Icon(Icons.delete_outline, color: SojornColors.destructive), icon: const Icon(Icons.delete_outline, color: SojornColors.destructive),
onPressed: _bulkDelete, onPressed: _bulkDelete,
), ),
] ] else if (widget.showNavActions) ...[
// Full thread header with nav buttons (used in regular post view)
IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
),
const SizedBox(width: 4),
Text(
'Thread',
style: GoogleFonts.inter(
fontWeight: FontWeight.w700,
fontSize: 18,
color: AppTheme.textPrimary,
),
),
const Spacer(),
IconButton(
onPressed: () => context.go(AppRoutes.homeAlias),
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () {},
icon: Icon(Icons.search, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () => context.go(AppRoutes.secureChat),
icon: Consumer(
builder: (context, ref, child) {
final badge = ref.watch(currentBadgeProvider);
return Badge(
label: Text(badge.messageCount.toString()),
isLabelVisible: badge.messageCount > 0,
backgroundColor: AppTheme.brightNavy,
child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
);
},
),
),
] else ...[
// Clean Quips-style header: "X Comments" + close X
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(
'$_commentCount Comment${_commentCount == 1 ? '' : 's'}',
style: GoogleFonts.inter(
fontWeight: FontWeight.w700,
fontSize: 17,
color: AppTheme.textPrimary,
),
),
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.close, color: AppTheme.navyBlue),
),
],
], ],
), ),
), ),

View file

@ -12,12 +12,15 @@ class VideoCommentsSheet extends StatefulWidget {
final String postId; final String postId;
final int initialCommentCount; final int initialCommentCount;
final VoidCallback? onCommentPosted; final VoidCallback? onCommentPosted;
/// Set to false for Quips feed (hides Home/Chat/Search nav icons in header)
final bool showNavActions;
const VideoCommentsSheet({ const VideoCommentsSheet({
super.key, super.key,
required this.postId, required this.postId,
this.initialCommentCount = 0, this.initialCommentCount = 0,
this.onCommentPosted, this.onCommentPosted,
this.showNavActions = true,
}); });
@override @override
@ -31,6 +34,7 @@ class _VideoCommentsSheetState extends State<VideoCommentsSheet> {
postId: widget.postId, postId: widget.postId,
initialQuipCount: widget.initialCommentCount, initialQuipCount: widget.initialCommentCount,
onQuipPosted: widget.onCommentPosted, onQuipPosted: widget.onCommentPosted,
showNavActions: widget.showNavActions,
); );
} }
} }