[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: crosspost replies viewer

+599 -67
+219 -30
lib/src/core/network/atproto/data/models/feed_models.dart
··· 960 960 ); 961 961 } 962 962 963 - factory Thread.fromSparkFlatList({required List<dynamic> threadItems}) { 963 + factory Thread.fromSparkFlatList({ 964 + required List<dynamic> threadItems, 965 + bool isCrosspostThread = false, 966 + }) { 964 967 if (threadItems.isEmpty) { 965 968 throw Exception('Thread items list is empty'); 966 969 } 967 970 971 + void ensureRecordType( 972 + Map<String, dynamic> postPayload, 973 + String postUnionType, 974 + ) { 975 + final record = postPayload['record']; 976 + if (record is! Map<String, dynamic>) return; 977 + 978 + final currentType = record[r'$type'] as String?; 979 + if (currentType != null && currentType.isNotEmpty) return; 980 + 981 + final inferredType = switch (postUnionType) { 982 + 'so.sprk.feed.defs#postView' => 'so.sprk.feed.post', 983 + 'so.sprk.feed.defs#replyView' => 984 + (record.containsKey('text') || 985 + record.containsKey('facets') || 986 + record.containsKey('embed')) 987 + ? 'app.bsky.feed.post' 988 + : 'so.sprk.feed.reply', 989 + _ => null, 990 + }; 991 + 992 + if (inferredType != null) { 993 + record[r'$type'] = inferredType; 994 + } 995 + } 996 + 997 + String? inferThreadPostViewType(Map<String, dynamic> postPayload) { 998 + final record = postPayload['record']; 999 + if (record is Map<String, dynamic>) { 1000 + final recordType = record[r'$type'] as String?; 1001 + if (recordType == 'so.sprk.feed.reply' || 1002 + recordType == 'app.bsky.feed.post') { 1003 + return 'so.sprk.feed.defs#replyView'; 1004 + } 1005 + if (recordType == 'so.sprk.feed.post') { 1006 + return 'so.sprk.feed.defs#postView'; 1007 + } 1008 + } 1009 + 1010 + final uri = postPayload['uri'] as String?; 1011 + if (uri != null) { 1012 + if (uri.contains('/so.sprk.feed.reply/')) { 1013 + return 'so.sprk.feed.defs#replyView'; 1014 + } 1015 + if (uri.contains('/so.sprk.feed.post/')) { 1016 + return 'so.sprk.feed.defs#postView'; 1017 + } 1018 + } 1019 + 1020 + return null; 1021 + } 1022 + 968 1023 // Parse all thread items with their indices 969 1024 final items = <({int index, int depth, String uri, Thread thread})>[]; 970 1025 for (var i = 0; i < threadItems.length; i++) { ··· 974 1029 final uri = itemMap['uri'] as String; 975 1030 final value = itemMap['value'] as Map<String, dynamic>; 976 1031 977 - // Ensure $type is set correctly for the thread 978 - if (!value.containsKey(r'$type')) { 979 - value[r'$type'] = 'so.sprk.feed.defs#threadViewPost'; 1032 + final itemType = itemMap[r'$type'] as String?; 1033 + final isCrosspostItem = 1034 + itemType == 'so.sprk.feed.getCrosspostThread#threadItem'; 1035 + final allowCrosspostNormalization = 1036 + isCrosspostThread || isCrosspostItem; 1037 + 1038 + var valueType = value[r'$type'] as String?; 1039 + 1040 + // Defensive fallback: some thread payloads can omit value.$type. 1041 + // Preserve prior behavior for standard getPostThread responses. 1042 + if (valueType == null) { 1043 + if (value['post'] != null) { 1044 + value[r'$type'] = 'so.sprk.feed.defs#threadViewPost'; 1045 + } else if (value['notFound'] == true) { 1046 + value[r'$type'] = 'so.sprk.feed.defs#notFoundPost'; 1047 + } else if (value['blocked'] == true) { 1048 + value[r'$type'] = 'so.sprk.feed.defs#blockedPost'; 1049 + } 1050 + valueType = value[r'$type'] as String?; 980 1051 } 981 1052 982 - // If it's a threadViewPost, ensure post field is properly structured 983 - if (value[r'$type'] == 'so.sprk.feed.defs#threadViewPost' && 984 - value['post'] != null) { 985 - final postMap = value['post'] as Map<String, dynamic>; 1053 + // Crosspost endpoint can return bare post/reply values. 1054 + if (allowCrosspostNormalization && 1055 + (valueType == null || 1056 + valueType == 'so.sprk.feed.defs#postView' || 1057 + valueType == 'so.sprk.feed.defs#replyView')) { 1058 + final normalizedPostType = valueType == 'so.sprk.feed.defs#replyView' 1059 + ? 'so.sprk.feed.defs#replyView' 1060 + : 'so.sprk.feed.defs#postView'; 1061 + final raw = Map<String, dynamic>.from(value)..remove(r'$type'); 1062 + value 1063 + ..clear() 1064 + ..[r'$type'] = 'so.sprk.feed.defs#threadViewPost' 1065 + ..['post'] = <String, dynamic>{ 1066 + r'$type': normalizedPostType, 1067 + normalizedPostType == 'so.sprk.feed.defs#replyView' 1068 + ? 'reply' 1069 + : 'post': 1070 + raw, 1071 + }; 1072 + } 986 1073 987 - // Determine the post/reply type based on the record type 988 - var postViewType = 'so.sprk.feed.defs#postView'; 989 - if (postMap['record'] != null) { 990 - final recordMap = postMap['record'] as Map<String, dynamic>; 991 - final recordType = recordMap[r'$type'] as String?; 1074 + final normalizedValueType = value[r'$type'] as String?; 1075 + final isAllowedValueType = 1076 + normalizedValueType == 'so.sprk.feed.defs#threadViewPost' || 1077 + normalizedValueType == 'so.sprk.feed.defs#notFoundPost' || 1078 + normalizedValueType == 'so.sprk.feed.defs#blockedPost'; 1079 + if (!isAllowedValueType) { 1080 + throw Exception( 1081 + 'Invalid thread item value type: $normalizedValueType', 1082 + ); 1083 + } 992 1084 993 - if (recordType == 'so.sprk.feed.reply') { 994 - postViewType = 'so.sprk.feed.defs#replyView'; 995 - } else if (recordType == 'so.sprk.feed.post') { 996 - postViewType = 'so.sprk.feed.defs#postView'; 997 - } 1085 + if (normalizedValueType == 'so.sprk.feed.defs#threadViewPost') { 1086 + if (value['post'] is! Map<String, dynamic>) { 1087 + throw Exception('Invalid threadViewPost: missing post map'); 998 1088 } 999 1089 1000 - // The API returns post/reply directly, but ThreadPost expects it wrapped 1001 - // ThreadPost is a union that wraps either PostView or ReplyView 1002 - // Set the correct $type and wrap accordingly 1003 - postMap[r'$type'] = postViewType; 1090 + var postContainer = value['post'] as Map<String, dynamic>; 1091 + var postContainerType = postContainer[r'$type'] as String?; 1092 + var postPayload = 1093 + (postContainer['post'] ?? postContainer['reply']) 1094 + as Map<String, dynamic>?; 1004 1095 1005 - if (postViewType == 'so.sprk.feed.defs#replyView') { 1006 - // Wrap as ThreadReplyView 1096 + // Canonicalize standard thread payloads where post/reply union type 1097 + // is present, but payload is not wrapped under post/reply key yet. 1098 + if (postContainerType == 'so.sprk.feed.defs#postView' && 1099 + postContainer['post'] is! Map<String, dynamic>) { 1100 + final rawPost = Map<String, dynamic>.from(postContainer) 1101 + ..remove(r'$type'); 1102 + value['post'] = <String, dynamic>{ 1103 + r'$type': 'so.sprk.feed.defs#postView', 1104 + 'post': rawPost, 1105 + }; 1106 + postPayload = rawPost; 1107 + } else if (postContainerType == 'so.sprk.feed.defs#replyView' && 1108 + postContainer['reply'] is! Map<String, dynamic>) { 1109 + final rawReply = Map<String, dynamic>.from(postContainer) 1110 + ..remove(r'$type'); 1007 1111 value['post'] = <String, dynamic>{ 1008 1112 r'$type': 'so.sprk.feed.defs#replyView', 1009 - 'reply': postMap, 1113 + 'reply': rawReply, 1010 1114 }; 1011 - } else { 1012 - // Wrap as ThreadPostView 1115 + postPayload = rawReply; 1116 + } 1117 + 1118 + postContainer = value['post'] as Map<String, dynamic>; 1119 + postContainerType = postContainer[r'$type'] as String?; 1120 + postPayload = 1121 + (postContainer['post'] ?? postContainer['reply']) 1122 + as Map<String, dynamic>?; 1123 + 1124 + final inferredFromPayload = postPayload != null 1125 + ? inferThreadPostViewType(postPayload) 1126 + : null; 1127 + if (inferredFromPayload != null && 1128 + postContainerType != inferredFromPayload) { 1013 1129 value['post'] = <String, dynamic>{ 1014 - r'$type': 'so.sprk.feed.defs#postView', 1015 - 'post': postMap, 1130 + r'$type': inferredFromPayload, 1131 + inferredFromPayload == 'so.sprk.feed.defs#replyView' 1132 + ? 'reply' 1133 + : 'post': 1134 + postPayload, 1016 1135 }; 1136 + postContainer = value['post'] as Map<String, dynamic>; 1137 + postContainerType = postContainer[r'$type'] as String?; 1138 + postPayload = 1139 + (postContainer['post'] ?? postContainer['reply']) 1140 + as Map<String, dynamic>?; 1141 + } 1142 + 1143 + final isValidWrappedPost = 1144 + postContainerType == 'so.sprk.feed.defs#postView' && 1145 + postContainer['post'] is Map<String, dynamic>; 1146 + final isValidWrappedReply = 1147 + postContainerType == 'so.sprk.feed.defs#replyView' && 1148 + postContainer['reply'] is Map<String, dynamic>; 1149 + 1150 + // Crosspost-only normalization for legacy/raw post container shapes. 1151 + if (!(isValidWrappedPost || isValidWrappedReply) && 1152 + allowCrosspostNormalization) { 1153 + final rawPost = Map<String, dynamic>.from(postContainer) 1154 + ..remove(r'$type'); 1155 + final inferredPostType = 1156 + inferThreadPostViewType(rawPost) ?? 1157 + 'so.sprk.feed.defs#postView'; 1158 + 1159 + value['post'] = <String, dynamic>{ 1160 + r'$type': inferredPostType, 1161 + inferredPostType == 'so.sprk.feed.defs#replyView' 1162 + ? 'reply' 1163 + : 'post': 1164 + rawPost, 1165 + }; 1166 + postContainer = value['post'] as Map<String, dynamic>; 1167 + postContainerType = postContainer[r'$type'] as String?; 1168 + postPayload = 1169 + (postContainer['post'] ?? postContainer['reply']) 1170 + as Map<String, dynamic>?; 1171 + } 1172 + 1173 + final isValidAfterNormalization = 1174 + (postContainerType == 'so.sprk.feed.defs#postView' && 1175 + value['post'] is Map<String, dynamic> && 1176 + (value['post'] as Map<String, dynamic>)['post'] 1177 + is Map<String, dynamic>) || 1178 + (postContainerType == 'so.sprk.feed.defs#replyView' && 1179 + value['post'] is Map<String, dynamic> && 1180 + (value['post'] as Map<String, dynamic>)['reply'] 1181 + is Map<String, dynamic>); 1182 + 1183 + if (!isValidAfterNormalization) { 1184 + throw Exception( 1185 + 'Invalid threadViewPost.post union shape: ' 1186 + 'type=$postContainerType', 1187 + ); 1188 + } 1189 + 1190 + if (allowCrosspostNormalization && 1191 + postPayload != null && 1192 + postContainerType != null) { 1193 + ensureRecordType(postPayload, postContainerType); 1017 1194 } 1018 1195 } 1019 1196 ··· 1037 1214 } catch (e) { 1038 1215 // Try to identify which field is failing 1039 1216 final postMap = value['post'] as Map<String, dynamic>?; 1040 - final recordMap = postMap?['record'] as Map<String, dynamic>?; 1217 + final postPayload = 1218 + (postMap?['post'] ?? postMap?['reply']) as Map<String, dynamic>?; 1219 + final recordMap = postPayload?['record'] as Map<String, dynamic>?; 1041 1220 throw Exception( 1042 1221 'Failed to parse Thread.fromJson: $e\n' 1043 1222 'Value keys: ${value.keys.join(", ")}\n' 1044 1223 'Post keys: ${postMap?.keys.join(", ")}\n' 1224 + 'Post payload keys: ${postPayload?.keys.join(", ")}\n' 1045 1225 'Record keys: ${recordMap?.keys.join(", ")}', 1046 1226 ); 1047 1227 } ··· 1074 1254 final item = items[i]; 1075 1255 1076 1256 if (item.depth <= currentDepth) { 1257 + final isRepeatedAnchorBoundary = 1258 + currentDepth == 0 && 1259 + item.depth == 0 && 1260 + item.uri == currentItem.uri; 1261 + if (isRepeatedAnchorBoundary) { 1262 + i++; 1263 + continue; 1264 + } 1265 + 1077 1266 // We've moved to a sibling or back up the tree 1078 1267 break; 1079 1268 }
+11
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 193 193 bool bluesky = false, 194 194 }); 195 195 196 + /// Get crosspost-only thread items for an anchor. 197 + /// 198 + /// Uses `so.sprk.feed.getCrosspostThread` and returns a thread structure 199 + /// derived from the flat list response. 200 + Future<Thread> getCrosspostThread( 201 + AtUri anchor, { 202 + int depth = 1, 203 + int parentHeight = 0, 204 + String sort = 'newest', 205 + }); 206 + 196 207 /// Get labels for a list of URIs 197 208 /// 198 209 /// [uris] List of post URIs to fetch labels for
+69
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 1481 1481 } 1482 1482 1483 1483 @override 1484 + Future<Thread> getCrosspostThread( 1485 + AtUri anchor, { 1486 + int depth = 1, 1487 + int parentHeight = 0, 1488 + String sort = 'newest', 1489 + }) async { 1490 + _logger.d('Getting crosspost thread for anchor: $anchor'); 1491 + 1492 + return _client.executeWithRetry(() async { 1493 + if (!_client.authRepository.isAuthenticated) { 1494 + _logger.w('Not authenticated'); 1495 + throw Exception('Not authenticated'); 1496 + } 1497 + 1498 + final atproto = _client.authRepository.atproto; 1499 + if (atproto == null) { 1500 + _logger.e('AtProto not initialized'); 1501 + throw Exception('AtProto not initialized'); 1502 + } 1503 + 1504 + const source = 'so.sprk.feed.getCrosspostThread'; 1505 + final threadItems = <dynamic>[]; 1506 + String? cursor; 1507 + 1508 + bool isAnchorItem(dynamic item) { 1509 + if (item is! Map<String, dynamic>) return false; 1510 + return item['depth'] == 0 && item['uri'] == anchor.toString(); 1511 + } 1512 + 1513 + do { 1514 + final response = await atproto.get( 1515 + NSID.parse(source), 1516 + parameters: { 1517 + 'anchor': anchor.toString(), 1518 + 'depth': depth, 1519 + 'parentHeight': parentHeight, 1520 + 'sort': sort, 1521 + 'limit': 100, 1522 + 'cursor': cursor, 1523 + }, 1524 + headers: {'atproto-proxy': _client.sprkDid}, 1525 + to: (jsonMap) => jsonMap, 1526 + adaptor: (uint8) => 1527 + jsonDecode(utf8.decode(uint8 as List<int>)) 1528 + as Map<String, dynamic>, 1529 + ); 1530 + 1531 + final pageItems = 1532 + response.data['thread'] as List<dynamic>? ?? const <dynamic>[]; 1533 + 1534 + var itemsToAppend = pageItems; 1535 + while (threadItems.isNotEmpty && 1536 + itemsToAppend.isNotEmpty && 1537 + isAnchorItem(itemsToAppend.first)) { 1538 + itemsToAppend = itemsToAppend.sublist(1); 1539 + } 1540 + 1541 + threadItems.addAll(itemsToAppend); 1542 + cursor = response.data['cursor'] as String?; 1543 + } while (cursor != null && cursor.isNotEmpty); 1544 + 1545 + return Thread.fromSparkFlatList( 1546 + threadItems: threadItems, 1547 + isCrosspostThread: true, 1548 + ); 1549 + }); 1550 + } 1551 + 1552 + @override 1484 1553 Future<({List<Label> labels, String? cursor})> getLabels( 1485 1554 List<AtUri> uris, { 1486 1555 List<String>? sources,
+4
lib/src/core/routing/app_router.dart
··· 93 93 children: [ 94 94 AutoRoute(page: CommentsListRoute.page, path: '', initial: true), 95 95 AutoRoute(page: RepliesRoute.page, path: 'replies/:postUri'), 96 + AutoRoute( 97 + page: CrosspostCommentsRoute.page, 98 + path: 'crossposts/:postUri', 99 + ), 96 100 ], 97 101 ), 98 102 CustomRoute(
+1
lib/src/core/routing/pages.dart
··· 4 4 export 'package:spark/src/features/auth/ui/pages/onboarding_page.dart'; 5 5 export 'package:spark/src/features/auth/ui/pages/register_page.dart'; 6 6 export 'package:spark/src/features/comments/ui/pages/comments_page.dart'; 7 + export 'package:spark/src/features/comments/ui/pages/crosspost_comments_page.dart'; 7 8 export 'package:spark/src/features/comments/ui/pages/replies_page.dart'; 8 9 export 'package:spark/src/features/feed/ui/pages/feed_page.dart'; 9 10 export 'package:spark/src/features/feed/ui/pages/feeds_page.dart';
+109 -37
lib/src/features/comments/ui/pages/comments_page.dart
··· 79 79 topLeft: Radius.circular(12), 80 80 topRight: Radius.circular(12), 81 81 ), 82 - child: SafeArea(child: AutoRouter()), 82 + child: SafeArea( 83 + child: Column( 84 + children: [ 85 + _CommentsTrayHandle(), 86 + Expanded(child: AutoRouter()), 87 + ], 88 + ), 89 + ), 90 + ), 91 + ), 92 + ); 93 + } 94 + } 95 + 96 + class _CommentsTrayHandle extends StatelessWidget { 97 + const _CommentsTrayHandle(); 98 + 99 + @override 100 + Widget build(BuildContext context) { 101 + final borderColor = Theme.of(context).colorScheme.outline; 102 + 103 + return Padding( 104 + padding: const EdgeInsets.symmetric(vertical: 12), 105 + child: Container( 106 + width: 40, 107 + height: 4, 108 + decoration: BoxDecoration( 109 + color: borderColor, 110 + borderRadius: BorderRadius.circular(2), 83 111 ), 84 112 ), 85 113 ); ··· 192 220 final threadPost = asyncState.value?.thread.post; 193 221 final displayPost = 194 222 _post ?? (threadPost is ThreadPostView ? threadPost.post : null); 195 - final commentCount = asyncState.value?.thread.replies?.length ?? 0; 223 + final visibleCommentCount = asyncState.value?.thread.replies?.length ?? 0; 224 + final totalCommentCount = 225 + asyncState.value?.thread.post.replyCount ?? visibleCommentCount; 196 226 final borderColor = Theme.of(context).colorScheme.outline; 197 227 final textColor = Theme.of(context).colorScheme.onSurface; 198 228 199 229 if (displayPost == null) { 200 230 return const Center(child: CircularProgressIndicator()); 201 231 } 232 + 233 + final hasCrossposts = displayPost.record.crossposts?.isNotEmpty ?? false; 202 234 203 235 return Column( 204 236 children: [ ··· 207 239 decoration: BoxDecoration( 208 240 border: Border(bottom: BorderSide(color: borderColor, width: 0.2)), 209 241 ), 210 - child: Column( 211 - children: [ 212 - Container( 213 - width: 40, 214 - height: 4, 215 - decoration: BoxDecoration( 216 - color: borderColor, 217 - borderRadius: BorderRadius.circular(2), 242 + child: Padding( 243 + padding: const EdgeInsets.symmetric(horizontal: 16), 244 + child: Row( 245 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 246 + children: [ 247 + Text( 248 + '$totalCommentCount comments', 249 + style: TextStyle( 250 + fontSize: 16, 251 + fontWeight: FontWeight.bold, 252 + color: textColor, 253 + ), 218 254 ), 219 - ), 220 - const SizedBox(height: 12), 221 - Padding( 222 - padding: const EdgeInsets.symmetric(horizontal: 16), 223 - child: Row( 224 - mainAxisAlignment: MainAxisAlignment.spaceBetween, 225 - children: [ 226 - Text( 227 - '$commentCount comments', 228 - style: TextStyle( 229 - fontSize: 16, 230 - fontWeight: FontWeight.bold, 231 - color: textColor, 232 - ), 233 - ), 234 - IconButton( 235 - padding: EdgeInsets.zero, 236 - constraints: const BoxConstraints(), 237 - onPressed: _closeComments, 238 - icon: Icon( 239 - FluentIcons.dismiss_24_regular, 240 - color: textColor, 241 - ), 242 - ), 243 - ], 255 + IconButton( 256 + padding: EdgeInsets.zero, 257 + constraints: const BoxConstraints(), 258 + onPressed: _closeComments, 259 + icon: Icon( 260 + FluentIcons.dismiss_24_regular, 261 + color: textColor, 262 + ), 244 263 ), 245 - ), 246 - ], 264 + ], 265 + ), 247 266 ), 248 267 ), 268 + if (hasCrossposts) 269 + _CrosspostCommentsBanner( 270 + onTap: () => context.router.push( 271 + CrosspostCommentsRoute(postUri: _postUri), 272 + ), 273 + ), 249 274 Expanded( 250 275 child: asyncState.when( 251 276 data: (data) { ··· 294 319 focusNode: _focusNode, 295 320 ), 296 321 ], 322 + ); 323 + } 324 + } 325 + 326 + class _CrosspostCommentsBanner extends StatelessWidget { 327 + const _CrosspostCommentsBanner({required this.onTap}); 328 + 329 + final VoidCallback onTap; 330 + 331 + @override 332 + Widget build(BuildContext context) { 333 + final colors = Theme.of(context).colorScheme; 334 + 335 + return Material( 336 + color: colors.surfaceContainerHighest.withValues(alpha: 0.45), 337 + child: InkWell( 338 + onTap: onTap, 339 + child: Padding( 340 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 341 + child: Row( 342 + children: [ 343 + Icon( 344 + FluentIcons.arrow_swap_24_regular, 345 + size: 18, 346 + color: colors.primary, 347 + ), 348 + const SizedBox(width: 8), 349 + Expanded( 350 + child: Text( 351 + 'Crosspost comments available', 352 + style: TextStyle( 353 + color: colors.onSurface, 354 + fontWeight: FontWeight.w600, 355 + ), 356 + ), 357 + ), 358 + Text( 359 + 'View', 360 + style: TextStyle( 361 + color: colors.primary, 362 + fontWeight: FontWeight.w700, 363 + ), 364 + ), 365 + ], 366 + ), 367 + ), 368 + ), 297 369 ); 298 370 } 299 371 }
+186
lib/src/features/comments/ui/pages/crosspost_comments_page.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:auto_route/auto_route.dart'; 3 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 + import 'package:get_it/get_it.dart'; 7 + import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 8 + import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 9 + import 'package:spark/src/core/ui/widgets/image_content.dart'; 10 + import 'package:spark/src/core/ui/widgets/user_avatar.dart'; 11 + 12 + final crosspostCommentsProvider = 13 + FutureProvider.family<List<ThreadViewPost>, AtUri>((ref, anchorUri) async { 14 + final feedRepository = GetIt.instance<SprkRepository>().feed; 15 + final thread = await feedRepository.getCrosspostThread(anchorUri); 16 + 17 + return switch (thread) { 18 + ThreadViewPost(:final replies) => 19 + replies?.whereType<ThreadViewPost>().toList() ?? 20 + const <ThreadViewPost>[], 21 + _ => const <ThreadViewPost>[], 22 + }; 23 + }); 24 + 25 + @RoutePage() 26 + class CrosspostCommentsPage extends ConsumerWidget { 27 + const CrosspostCommentsPage({required this.postUri, super.key}); 28 + 29 + final String postUri; 30 + 31 + @override 32 + Widget build(BuildContext context, WidgetRef ref) { 33 + final anchorUri = AtUri.parse(postUri); 34 + final asyncComments = ref.watch(crosspostCommentsProvider(anchorUri)); 35 + final textColor = Theme.of(context).colorScheme.onSurface; 36 + final borderColor = Theme.of(context).colorScheme.outline; 37 + 38 + return Material( 39 + color: Theme.of(context).colorScheme.surface, 40 + child: Column( 41 + children: [ 42 + Container( 43 + padding: const EdgeInsets.symmetric(vertical: 12), 44 + decoration: BoxDecoration( 45 + border: Border( 46 + bottom: BorderSide(color: borderColor, width: 0.2), 47 + ), 48 + ), 49 + child: Padding( 50 + padding: const EdgeInsets.symmetric(horizontal: 16), 51 + child: Row( 52 + children: [ 53 + IconButton( 54 + padding: EdgeInsets.zero, 55 + constraints: const BoxConstraints(), 56 + onPressed: () => context.router.maybePop(), 57 + icon: Icon( 58 + FluentIcons.chevron_left_24_regular, 59 + color: textColor, 60 + ), 61 + ), 62 + const SizedBox(width: 8), 63 + Text( 64 + 'Crosspost comments', 65 + style: TextStyle( 66 + fontSize: 16, 67 + fontWeight: FontWeight.bold, 68 + color: textColor, 69 + ), 70 + ), 71 + ], 72 + ), 73 + ), 74 + ), 75 + Expanded( 76 + child: asyncComments.when( 77 + data: (comments) { 78 + if (comments.isEmpty) { 79 + return const Center( 80 + child: Text('No crosspost comments yet.'), 81 + ); 82 + } 83 + 84 + return ListView.separated( 85 + itemCount: comments.length, 86 + separatorBuilder: (context, index) => Divider( 87 + height: 1, 88 + color: Theme.of(context).colorScheme.outlineVariant, 89 + ), 90 + itemBuilder: (context, index) { 91 + return _CrosspostCommentTile( 92 + comment: comments[index], 93 + textColor: textColor, 94 + ); 95 + }, 96 + ); 97 + }, 98 + loading: () => const Center(child: CircularProgressIndicator()), 99 + error: (error, stackTrace) => 100 + Center(child: Text('Error: $error')), 101 + ), 102 + ), 103 + ], 104 + ), 105 + ); 106 + } 107 + } 108 + 109 + class _CrosspostCommentTile extends StatelessWidget { 110 + const _CrosspostCommentTile({required this.comment, required this.textColor}); 111 + 112 + final ThreadViewPost comment; 113 + final Color textColor; 114 + 115 + @override 116 + Widget build(BuildContext context) { 117 + final author = comment.post.author; 118 + final imageUrls = comment.post.imageUrls; 119 + 120 + return Padding( 121 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 122 + child: Row( 123 + crossAxisAlignment: CrossAxisAlignment.start, 124 + children: [ 125 + UserAvatar( 126 + imageUrl: author.avatar?.toString() ?? '', 127 + username: author.handle, 128 + size: 36, 129 + ), 130 + const SizedBox(width: 12), 131 + Expanded( 132 + child: Column( 133 + crossAxisAlignment: CrossAxisAlignment.start, 134 + children: [ 135 + Row( 136 + children: [ 137 + Expanded( 138 + child: Text( 139 + author.handle, 140 + style: TextStyle( 141 + color: textColor, 142 + fontWeight: FontWeight.w700, 143 + ), 144 + ), 145 + ), 146 + Text( 147 + _formatRelative(comment.post.indexedAt), 148 + style: TextStyle( 149 + color: Theme.of(context).colorScheme.onSurfaceVariant, 150 + fontSize: 12, 151 + ), 152 + ), 153 + ], 154 + ), 155 + if (comment.post.displayText.isNotEmpty) ...[ 156 + const SizedBox(height: 4), 157 + Text(comment.post.displayText), 158 + ], 159 + if (imageUrls.isNotEmpty) ...[ 160 + const SizedBox(height: 8), 161 + ImageContent( 162 + imageUrls: imageUrls, 163 + borderRadius: BorderRadius.circular(8), 164 + thumbnailSize: 120, 165 + ), 166 + ], 167 + ], 168 + ), 169 + ), 170 + ], 171 + ), 172 + ); 173 + } 174 + 175 + String _formatRelative(DateTime timestamp) { 176 + final now = DateTime.now(); 177 + final difference = now.difference(timestamp.toLocal()); 178 + 179 + if (difference.inDays > 365) return '${(difference.inDays / 365).floor()}y'; 180 + if (difference.inDays > 30) return '${(difference.inDays / 30).floor()}mo'; 181 + if (difference.inDays > 0) return '${difference.inDays}d'; 182 + if (difference.inHours > 0) return '${difference.inHours}h'; 183 + if (difference.inMinutes > 0) return '${difference.inMinutes}m'; 184 + return 'now'; 185 + } 186 + }