mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
at main 399 lines 12 kB view raw
1import 'dart:convert'; 2 3import 'package:drift/drift.dart' hide JsonKey; 4import 'package:freezed_annotation/freezed_annotation.dart'; 5import 'package:lazurite/src/core/domain/author.dart' as core; 6import 'package:lazurite/src/core/domain/content_label.dart'; 7import 'package:lazurite/src/core/domain/post.dart' as domain; 8import 'package:lazurite/src/infrastructure/db/app_database.dart'; 9import 'package:lazurite/src/infrastructure/db/daos/feed_content_dao.dart'; 10 11part 'thread.freezed.dart'; 12part 'thread.g.dart'; 13 14@Freezed(unionKey: r'$type', unionValueCase: FreezedUnionCase.pascal) 15sealed class ThreadViewPost with _$ThreadViewPost { 16 const ThreadViewPost._(); 17 18 @FreezedUnionValue('app.bsky.feed.defs#threadViewPost') 19 const factory ThreadViewPost.item({ 20 required ThreadPost post, 21 ThreadViewPost? parent, 22 @Default([]) List<ThreadViewPost> replies, 23 Threadgate? threadgate, 24 }) = _ThreadViewPostView; 25 26 @FreezedUnionValue('app.bsky.feed.defs#blockedPost') 27 const factory ThreadViewPost.blocked({ 28 required String uri, 29 @Default(true) bool blocked, 30 ThreadAuthor? author, 31 }) = _ThreadViewPostBlocked; 32 33 @FreezedUnionValue('app.bsky.feed.defs#notFoundPost') 34 const factory ThreadViewPost.notFound({required String uri, @Default(true) bool notFound}) = 35 _ThreadViewPostNotFound; 36 37 factory ThreadViewPost({ 38 required ThreadPost post, 39 ThreadViewPost? parent, 40 List<ThreadViewPost> replies = const [], 41 Threadgate? threadgate, 42 bool isBlocked = false, 43 bool isNotFound = false, 44 }) { 45 if (isBlocked) { 46 return ThreadViewPost.blocked(uri: post.uri, blocked: true, author: post.author); 47 } 48 if (isNotFound) { 49 return ThreadViewPost.notFound(uri: post.uri, notFound: true); 50 } 51 return ThreadViewPost.item( 52 post: post, 53 parent: parent, 54 replies: replies, 55 threadgate: threadgate, 56 ); 57 } 58 59 factory ThreadViewPost.fromJson(Map<String, dynamic> json) => _$ThreadViewPostFromJson(json); 60 61 ThreadPost get post { 62 return maybeWhen( 63 item: (post, _, _, _) => post, 64 blocked: (uri, _, _) => 65 ThreadPost.placeholder(uri: uri, reason: 'Post blocked', isBlocked: true), 66 notFound: (uri, _) => 67 ThreadPost.placeholder(uri: uri, reason: 'Post not found', isNotFound: true), 68 orElse: () => ThreadPost.placeholder(uri: 'unknown', reason: 'Unsupported thread item'), 69 ); 70 } 71 72 ThreadViewPost? get parent => maybeWhen(item: (_, parent, _, _) => parent, orElse: () => null); 73 74 List<ThreadViewPost> get replies => 75 maybeWhen(item: (_, _, replies, _) => replies, orElse: () => const []); 76 77 Threadgate? get threadgate => 78 maybeWhen(item: (_, _, _, threadgate) => threadgate, orElse: () => null); 79 80 bool get isBlocked => maybeWhen(blocked: (_, _, _) => true, orElse: () => false); 81 82 bool get isNotFound => maybeWhen(notFound: (_, _) => true, orElse: () => false); 83 84 List<ThreadViewPost> get ancestorChain { 85 final chain = <ThreadViewPost>[]; 86 var current = parent; 87 while (current != null) { 88 chain.add(current); 89 current = current.parent; 90 } 91 return chain.reversed.toList(); 92 } 93} 94 95/// Domain model for a thread post. 96@freezed 97abstract class ThreadPost with _$ThreadPost { 98 factory ThreadPost.fromJson(Map<String, dynamic> json) => _$ThreadPostFromJson(json); 99 100 factory ThreadPost.placeholder({ 101 required String uri, 102 required String reason, 103 bool isBlocked = false, 104 bool isNotFound = false, 105 }) => ThreadPost( 106 uri: uri, 107 cid: uri, 108 author: ThreadAuthor(did: 'placeholder:$uri', handle: 'unknown', displayName: reason), 109 record: {'text': reason}, 110 placeholderReason: reason, 111 indexedAt: DateTime.now(), 112 isBlocked: isBlocked, 113 isNotFound: isNotFound, 114 ); 115 116 const factory ThreadPost({ 117 required String uri, 118 String? cid, 119 required ThreadAuthor author, 120 required Map<String, dynamic> record, 121 @JsonKey(fromJson: _transformEmbed) String? embed, 122 DateTime? indexedAt, 123 @Default(0) int replyCount, 124 @Default(0) int repostCount, 125 @Default(0) int likeCount, 126 @Default(0) int quoteCount, 127 @Default(0) int bookmarkCount, 128 List<ContentLabel>? labels, 129 PostViewer? viewer, 130 String? placeholderReason, 131 @Default(false) bool isBlocked, 132 @Default(false) bool isNotFound, 133 }) = _ThreadPost; 134 135 const ThreadPost._(); 136 137 String get effectiveCid => cid ?? uri; 138 139 String? get viewerLikeUri => viewer?.like; 140 String? get viewerRepostUri => viewer?.repost; 141 bool get viewerBookmarked => viewer?.bookmarked ?? false; 142 bool get viewerThreadMuted => viewer?.threadMuted ?? false; 143 bool get viewerReplyDisabled => viewer?.replyDisabled ?? false; 144 145 @override 146 Map<String, dynamic> toJson() => _$ThreadPostToJson(this as _ThreadPost); 147 148 PostsCompanion toPostsCompanion() => PostsCompanion.insert( 149 uri: uri, 150 cid: effectiveCid, 151 authorDid: author.did, 152 record: jsonEncode(record), 153 embed: Value(embed), 154 indexedAt: Value(indexedAt), 155 replyCount: Value(replyCount), 156 repostCount: Value(repostCount), 157 likeCount: Value(likeCount), 158 quoteCount: Value(quoteCount), 159 bookmarkCount: Value(bookmarkCount), 160 labels: Value(labels != null ? jsonEncode(labels) : null), 161 viewerLikeUri: Value(viewer?.like), 162 viewerRepostUri: Value(viewer?.repost), 163 viewerBookmarked: Value(viewer?.bookmarked ?? false), 164 viewerThreadMuted: Value(viewer?.threadMuted ?? false), 165 viewerReplyDisabled: Value(viewer?.replyDisabled ?? false), 166 ); 167 168 ProfilesCompanion toProfilesCompanion() => ProfilesCompanion.insert( 169 did: author.did, 170 handle: author.handle, 171 displayName: Value(author.displayName ?? placeholderReason), 172 description: Value(author.description), 173 avatar: Value(author.avatar), 174 indexedAt: Value(indexedAt), 175 ); 176 177 ProfileRelationshipsCompanion? toRelationshipCompanion(String ownerDid) { 178 final v = author.viewer; 179 if (v == null) return null; 180 181 return ProfileRelationshipsCompanion.insert( 182 ownerDid: ownerDid, 183 profileDid: author.did, 184 following: Value(v.following != null), 185 followingUri: Value(v.following), 186 followedBy: Value(v.followedBy != null), 187 muted: Value(v.muted), 188 blocked: Value(v.blocking != null), 189 blockingUri: Value(v.blocking), 190 blockedBy: Value(v.blockedBy), 191 mutedByList: Value(v.mutedByList), 192 blockingByList: Value(v.blockingByList), 193 updatedAt: DateTime.now(), 194 ); 195 } 196 197 domain.Post toPostModel() => domain.Post( 198 uri: uri, 199 cid: effectiveCid, 200 author: author.toAuthorModel(), 201 text: record['text'] as String? ?? '', 202 embed: embed != null ? jsonDecode(embed!) as Map<String, dynamic> : null, 203 record: record, 204 indexedAt: indexedAt, 205 replyCount: replyCount, 206 repostCount: repostCount, 207 likeCount: likeCount, 208 viewerLikeUri: viewer?.like, 209 viewerRepostUri: viewer?.repost, 210 viewerBookmarked: viewer?.bookmarked ?? false, 211 ); 212 213 Profile toProfileModel() { 214 return Profile( 215 did: author.did, 216 handle: author.handle, 217 displayName: author.displayName ?? placeholderReason, 218 description: author.description, 219 avatar: author.avatar, 220 indexedAt: indexedAt, 221 ); 222 } 223 224 FeedPost toFeedPost({String? reason}) => FeedPost( 225 post: Post( 226 uri: uri, 227 cid: effectiveCid, 228 authorDid: author.did, 229 record: jsonEncode(record), 230 embed: embed, 231 indexedAt: indexedAt, 232 replyCount: replyCount, 233 repostCount: repostCount, 234 likeCount: likeCount, 235 quoteCount: quoteCount, 236 bookmarkCount: bookmarkCount, 237 labels: labels != null ? jsonEncode(labels) : null, 238 viewerLikeUri: viewer?.like, 239 viewerRepostUri: viewer?.repost, 240 viewerBookmarked: viewer?.bookmarked ?? false, 241 viewerThreadMuted: viewer?.threadMuted ?? false, 242 viewerReplyDisabled: viewer?.replyDisabled ?? false, 243 ), 244 author: toProfileModel(), 245 reason: reason, 246 ); 247} 248 249String? _transformEmbed(Object? embed) { 250 if (embed == null) return null; 251 if (embed is String) return embed; 252 return jsonEncode(embed); 253} 254 255@freezed 256abstract class PostViewer with _$PostViewer { 257 const factory PostViewer({ 258 String? like, 259 String? repost, 260 @Default(false) bool bookmarked, 261 @Default(false) bool threadMuted, 262 @Default(false) bool replyDisabled, 263 String? embedding, 264 }) = _PostViewer; 265 266 factory PostViewer.fromJson(Map<String, dynamic> json) => _$PostViewerFromJson(json); 267 268 @override 269 Map<String, dynamic> toJson() => _$PostViewerToJson(this as _PostViewer); 270} 271 272@freezed 273abstract class ThreadAuthor with _$ThreadAuthor { 274 const factory ThreadAuthor({ 275 required String did, 276 required String handle, 277 String? displayName, 278 String? description, 279 String? avatar, 280 ActorViewer? viewer, 281 List<ContentLabel>? labels, 282 }) = _ThreadAuthor; 283 284 const ThreadAuthor._(); 285 286 factory ThreadAuthor.fromJson(Map<String, dynamic> json) => _$ThreadAuthorFromJson(json); 287 288 @override 289 Map<String, dynamic> toJson() => _$ThreadAuthorToJson(this as _ThreadAuthor); 290 291 core.Author toAuthorModel() { 292 return core.Author(did: did, handle: handle, displayName: displayName, avatar: avatar); 293 } 294 295 ProfilesCompanion toProfilesCompanion() { 296 return ProfilesCompanion.insert( 297 did: did, 298 handle: handle, 299 displayName: Value(displayName), 300 description: Value(description), 301 avatar: Value(avatar), 302 ); 303 } 304 305 ProfileRelationshipsCompanion? toRelationshipCompanion(String ownerDid) { 306 final v = viewer; 307 if (v == null) return null; 308 309 return ProfileRelationshipsCompanion.insert( 310 ownerDid: ownerDid, 311 profileDid: did, 312 following: Value(v.following != null), 313 followingUri: Value(v.following), 314 followedBy: Value(v.followedBy != null), 315 muted: Value(v.muted), 316 blocked: Value(v.blocking != null), 317 blockingUri: Value(v.blocking), 318 blockedBy: Value(v.blockedBy), 319 mutedByList: Value(v.mutedByList), 320 blockingByList: Value(v.blockingByList), 321 updatedAt: DateTime.now(), 322 ); 323 } 324} 325 326@freezed 327abstract class ActorViewer with _$ActorViewer { 328 const factory ActorViewer({ 329 String? following, 330 String? followedBy, 331 @Default(false) bool muted, 332 String? blocking, 333 @Default(false) bool blockedBy, 334 String? mutedByList, 335 String? blockingByList, 336 @Default(false) bool knownFollowers, 337 }) = _ActorViewer; 338 339 factory ActorViewer.fromJson(Map<String, dynamic> json) => _$ActorViewerFromJson(json); 340 341 @override 342 Map<String, dynamic> toJson() => _$ActorViewerToJson(this as _ActorViewer); 343} 344 345@freezed 346abstract class Threadgate with _$Threadgate { 347 const factory Threadgate({ 348 required String uri, 349 String? cid, 350 ThreadgateRecord? record, 351 @Default([]) List<Map<String, dynamic>> lists, 352 }) = _Threadgate; 353 354 const Threadgate._(); 355 356 factory Threadgate.fromJson(Map<String, dynamic> json) => _$ThreadgateFromJson(json); 357 358 @override 359 Map<String, dynamic> toJson() => _$ThreadgateToJson(this as _Threadgate); 360 361 /// Returns readable description of reply restriction. 362 String get restrictionDescription { 363 if (record == null) return 'Replies restricted'; 364 final allowRules = record!.allow; 365 if (allowRules.isEmpty) return 'Replies disabled'; 366 367 final descriptions = <String>[]; 368 for (final rule in allowRules) { 369 final type = rule[r'$type'] as String?; 370 switch (type) { 371 case 'app.bsky.feed.threadgate#mentionRule': 372 descriptions.add('mentioned users'); 373 case 'app.bsky.feed.threadgate#followingRule': 374 descriptions.add('accounts the author follows'); 375 case 'app.bsky.feed.threadgate#listRule': 376 descriptions.add('list members'); 377 default: 378 descriptions.add('specific users'); 379 } 380 } 381 return 'Replies limited to ${descriptions.join(', ')}'; 382 } 383} 384 385@freezed 386abstract class ThreadgateRecord with _$ThreadgateRecord { 387 const factory ThreadgateRecord({ 388 required String post, 389 @Default([]) List<Map<String, dynamic>> allow, 390 DateTime? createdAt, 391 }) = _ThreadgateRecord; 392 393 const ThreadgateRecord._(); 394 395 factory ThreadgateRecord.fromJson(Map<String, dynamic> json) => _$ThreadgateRecordFromJson(json); 396 397 @override 398 Map<String, dynamic> toJson() => _$ThreadgateRecordToJson(this as _ThreadgateRecord); 399}