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