1// Post data models for Coves timeline feed 2// 3// These models match the backend response structure from: 4// /xrpc/social.coves.feed.getTimeline 5// /xrpc/social.coves.feed.getDiscover 6 7class TimelineResponse { 8 TimelineResponse({required this.feed, this.cursor}); 9 10 factory TimelineResponse.fromJson(Map<String, dynamic> json) { 11 // Handle null feed array from backend 12 final feedData = json['feed']; 13 final List<FeedViewPost> feedList; 14 15 if (feedData == null) { 16 // Backend returned null, use empty list 17 feedList = []; 18 } else { 19 // Parse feed items 20 feedList = 21 (feedData as List<dynamic>) 22 .map( 23 (item) => FeedViewPost.fromJson(item as Map<String, dynamic>), 24 ) 25 .toList(); 26 } 27 28 return TimelineResponse(feed: feedList, cursor: json['cursor'] as String?); 29 } 30 final List<FeedViewPost> feed; 31 final String? cursor; 32} 33 34class FeedViewPost { 35 FeedViewPost({required this.post, this.reason}); 36 37 factory FeedViewPost.fromJson(Map<String, dynamic> json) { 38 return FeedViewPost( 39 post: PostView.fromJson(json['post'] as Map<String, dynamic>), 40 reason: 41 json['reason'] != null 42 ? FeedReason.fromJson(json['reason'] as Map<String, dynamic>) 43 : null, 44 ); 45 } 46 final PostView post; 47 final FeedReason? reason; 48} 49 50class ViewerState { 51 ViewerState({ 52 this.vote, 53 this.voteUri, 54 this.saved = false, 55 this.savedUri, 56 this.tags, 57 }); 58 59 factory ViewerState.fromJson(Map<String, dynamic> json) { 60 return ViewerState( 61 vote: json['vote'] as String?, 62 voteUri: json['voteUri'] as String?, 63 saved: json['saved'] as bool? ?? false, 64 savedUri: json['savedUri'] as String?, 65 tags: (json['tags'] as List<dynamic>?)?.cast<String>(), 66 ); 67 } 68 69 /// Vote direction: "up", "down", or null if not voted 70 final String? vote; 71 72 /// AT-URI of the vote record 73 final String? voteUri; 74 75 /// Whether the post is saved/bookmarked 76 final bool saved; 77 78 /// AT-URI of the saved record 79 final String? savedUri; 80 81 /// User-applied tags 82 final List<String>? tags; 83} 84 85class PostView { 86 PostView({ 87 required this.uri, 88 required this.cid, 89 required this.rkey, 90 required this.author, 91 required this.community, 92 required this.createdAt, 93 required this.indexedAt, 94 required this.text, 95 this.title, 96 required this.stats, 97 this.embed, 98 this.facets, 99 this.viewer, 100 }); 101 102 factory PostView.fromJson(Map<String, dynamic> json) { 103 return PostView( 104 uri: json['uri'] as String, 105 cid: json['cid'] as String, 106 rkey: json['rkey'] as String, 107 author: AuthorView.fromJson(json['author'] as Map<String, dynamic>), 108 community: CommunityRef.fromJson( 109 json['community'] as Map<String, dynamic>, 110 ), 111 createdAt: DateTime.parse(json['createdAt'] as String), 112 indexedAt: DateTime.parse(json['indexedAt'] as String), 113 text: json['text'] as String, 114 title: json['title'] as String?, 115 stats: PostStats.fromJson(json['stats'] as Map<String, dynamic>), 116 embed: 117 json['embed'] != null 118 ? PostEmbed.fromJson(json['embed'] as Map<String, dynamic>) 119 : null, 120 facets: 121 json['facets'] != null 122 ? (json['facets'] as List<dynamic>) 123 .map((f) => PostFacet.fromJson(f as Map<String, dynamic>)) 124 .toList() 125 : null, 126 viewer: 127 json['viewer'] != null 128 ? ViewerState.fromJson(json['viewer'] as Map<String, dynamic>) 129 : null, 130 ); 131 } 132 final String uri; 133 final String cid; 134 final String rkey; 135 final AuthorView author; 136 final CommunityRef community; 137 final DateTime createdAt; 138 final DateTime indexedAt; 139 final String text; 140 final String? title; 141 final PostStats stats; 142 final PostEmbed? embed; 143 final List<PostFacet>? facets; 144 final ViewerState? viewer; 145} 146 147class AuthorView { 148 AuthorView({ 149 required this.did, 150 required this.handle, 151 this.displayName, 152 this.avatar, 153 }); 154 155 factory AuthorView.fromJson(Map<String, dynamic> json) { 156 return AuthorView( 157 did: json['did'] as String, 158 handle: json['handle'] as String, 159 displayName: json['displayName'] as String?, 160 avatar: json['avatar'] as String?, 161 ); 162 } 163 final String did; 164 final String handle; 165 final String? displayName; 166 final String? avatar; 167} 168 169class CommunityRef { 170 CommunityRef({ 171 required this.did, 172 required this.name, 173 this.handle, 174 this.avatar, 175 }); 176 177 factory CommunityRef.fromJson(Map<String, dynamic> json) { 178 return CommunityRef( 179 did: json['did'] as String, 180 name: json['name'] as String, 181 handle: json['handle'] as String?, 182 avatar: json['avatar'] as String?, 183 ); 184 } 185 final String did; 186 final String name; 187 final String? handle; 188 final String? avatar; 189} 190 191class PostStats { 192 PostStats({ 193 required this.upvotes, 194 required this.downvotes, 195 required this.score, 196 required this.commentCount, 197 }); 198 199 factory PostStats.fromJson(Map<String, dynamic> json) { 200 return PostStats( 201 upvotes: json['upvotes'] as int, 202 downvotes: json['downvotes'] as int, 203 score: json['score'] as int, 204 commentCount: json['commentCount'] as int, 205 ); 206 } 207 final int upvotes; 208 final int downvotes; 209 final int score; 210 final int commentCount; 211} 212 213class PostEmbed { 214 PostEmbed({required this.type, this.external, required this.data}); 215 216 factory PostEmbed.fromJson(Map<String, dynamic> json) { 217 final embedType = json[r'$type'] as String? ?? 'unknown'; 218 ExternalEmbed? externalEmbed; 219 220 if (embedType == 'social.coves.embed.external' && 221 json['external'] != null) { 222 externalEmbed = ExternalEmbed.fromJson( 223 json['external'] as Map<String, dynamic>, 224 ); 225 } 226 227 return PostEmbed(type: embedType, external: externalEmbed, data: json); 228 } 229 final String type; 230 final ExternalEmbed? external; 231 final Map<String, dynamic> data; 232} 233 234class ExternalEmbed { 235 ExternalEmbed({ 236 required this.uri, 237 this.title, 238 this.description, 239 this.thumb, 240 this.domain, 241 this.embedType, 242 this.provider, 243 this.images, 244 this.totalCount, 245 }); 246 247 factory ExternalEmbed.fromJson(Map<String, dynamic> json) { 248 // Thumb is always a string URL (backend transforms blob refs 249 // before sending) 250 251 // Handle images array if present 252 List<Map<String, dynamic>>? imagesList; 253 if (json['images'] != null && json['images'] is List) { 254 imagesList = 255 (json['images'] as List).whereType<Map<String, dynamic>>().toList(); 256 } 257 258 return ExternalEmbed( 259 uri: json['uri'] as String, 260 title: json['title'] as String?, 261 description: json['description'] as String?, 262 thumb: json['thumb'] as String?, 263 domain: json['domain'] as String?, 264 embedType: json['embedType'] as String?, 265 provider: json['provider'] as String?, 266 images: imagesList, 267 totalCount: json['totalCount'] as int?, 268 ); 269 } 270 final String uri; 271 final String? title; 272 final String? description; 273 final String? thumb; 274 final String? domain; 275 final String? embedType; 276 final String? provider; 277 final List<Map<String, dynamic>>? images; 278 final int? totalCount; 279} 280 281class PostFacet { 282 PostFacet({required this.data}); 283 284 factory PostFacet.fromJson(Map<String, dynamic> json) { 285 return PostFacet(data: json); 286 } 287 final Map<String, dynamic> data; 288} 289 290class FeedReason { 291 FeedReason({required this.type, required this.data}); 292 293 factory FeedReason.fromJson(Map<String, dynamic> json) { 294 return FeedReason(type: json[r'$type'] as String? ?? 'unknown', data: json); 295 } 296 final String type; 297 final Map<String, dynamic> data; 298}