mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
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}