sojorn/sojorn_app/lib/providers/reactions_provider.dart
Patrick Britton 93a2c45a92 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>
2026-02-18 08:11:08 -06:00

135 lines
4.2 KiB
Dart

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 = [
'❤️', '👍', '😂', '😮', '😢', '😡',
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
'😍', '🤣', '😊', '👌', '🙌', '💪',
'🎯', '', '', '🌟', '💫', '☀️',
];