mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
1import 'package:atproto_core/atproto_core.dart' show AtUri;
2import 'package:bluesky/app_bsky_actor_defs.dart';
3import 'package:bluesky/app_bsky_graph_defs.dart';
4import 'package:equatable/equatable.dart';
5import 'package:lazurite/core/logging/app_logger.dart';
6import 'package:lazurite/core/network/constellation_client.dart';
7
8const _blockedByPageSize = 16;
9const _listsPageSize = 16;
10
11class UnavailableProfileRef extends Equatable {
12 const UnavailableProfileRef({required this.did, required this.reason});
13
14 final String did;
15 final String reason;
16
17 @override
18 List<Object?> get props => [did, reason];
19}
20
21class BlockedByEntry extends Equatable {
22 BlockedByEntry.profile({required ProfileView this.profile}) : did = profile.did, unavailableReason = null;
23
24 const BlockedByEntry.unavailable({required this.did, required this.unavailableReason}) : profile = null;
25
26 final String did;
27 final ProfileView? profile;
28 final String? unavailableReason;
29
30 bool get isAvailable => profile != null;
31
32 @override
33 List<Object?> get props => [did, profile, unavailableReason];
34}
35
36class ProfileContextRepository {
37 ProfileContextRepository({
38 required dynamic bluesky,
39 dynamic publicBluesky,
40 required ConstellationClient constellationClient,
41 }) : _bluesky = bluesky,
42 _publicBluesky = publicBluesky ?? bluesky,
43 _constellation = constellationClient;
44
45 final dynamic _bluesky;
46 final dynamic _publicBluesky;
47 final ConstellationClient _constellation;
48
49 /// Returns the number of accounts that have blocked [did].
50 Future<int> getBlockedByCount(String did) async {
51 return _constellation.getBacklinksCount(did, 'app.bsky.graph.block:subject');
52 }
53
54 /// Returns the number of lists that [did] is a member of.
55 Future<int> getListsOnCount(String did) async {
56 return _constellation.getBacklinksCount(did, 'app.bsky.graph.listitem:subject');
57 }
58
59 /// Returns a page of profiles that have blocked [did], along with the total
60 /// count and a cursor for the next page.
61 Future<({List<BlockedByEntry> entries, String? cursor, int total})> getBlockedByProfiles(
62 String did, {
63 String? cursor,
64 }) async {
65 final offset = int.tryParse(cursor ?? '0') ?? 0;
66 log.i('ProfileContextRepository: blocked-by load start for $did offset=$offset via ${_constellation.baseUrl}');
67 final collected = await _collectBlockedByDids(did);
68 final hydrated = await _hydrateProfiles(collected.dids);
69 final unavailableByDid = <String, String>{for (final entry in hydrated.unavailable) entry.did: entry.reason};
70 final profileByDid = <String, ProfileView>{for (final profile in hydrated.profiles) profile.did: profile};
71
72 final pageDids = collected.dids.skip(offset).take(_blockedByPageSize).toList();
73 final entries = <BlockedByEntry>[];
74 for (final pageDid in pageDids) {
75 final profile = profileByDid[pageDid];
76 if (profile != null) {
77 entries.add(BlockedByEntry.profile(profile: profile));
78 continue;
79 }
80
81 final unavailableReason = unavailableByDid[pageDid];
82 if (unavailableReason != null) {
83 entries.add(BlockedByEntry.unavailable(did: pageDid, unavailableReason: unavailableReason));
84 }
85 }
86
87 final nextOffset = offset + _blockedByPageSize;
88 log.i(
89 'ProfileContextRepository: blocked-by load complete for $did total=${collected.total} dids=${collected.dids.length} resolved=${hydrated.profiles.length} unavailable=${hydrated.unavailable.length} returned=${entries.length}',
90 );
91 return (
92 entries: entries,
93 cursor: nextOffset < collected.dids.length ? '$nextOffset' : null,
94 total: collected.total,
95 );
96 }
97
98 /// Returns the total number of accounts that [did] is blocking.
99 Future<int> getBlockingCount(String did) async {
100 _assertCurrentSessionRepoAccess(did: did, operation: 'getBlockingCount');
101 var total = 0;
102 String? cursor;
103
104 do {
105 final response = await _bluesky.atproto.repo.listRecords(
106 repo: did,
107 collection: 'app.bsky.graph.block',
108 limit: 100,
109 cursor: cursor,
110 );
111
112 total += (response.data.records as List<dynamic>).length;
113 cursor = response.data.cursor as String?;
114 } while (cursor != null);
115
116 return total;
117 }
118
119 /// Returns a page of profiles that [did] is blocking, along with a cursor.
120 /// Uses `com.atproto.repo.listRecords` on the actor's own repo.
121 /// [total] reflects the number of profiles hydrated in this page.
122 Future<({List<ProfileView> profiles, List<UnavailableProfileRef> unavailable, String? cursor, int total})>
123 getBlockingProfiles(String did, {String? cursor}) async {
124 _assertCurrentSessionRepoAccess(did: did, operation: 'getBlockingProfiles');
125 final response = await _bluesky.atproto.repo.listRecords(
126 repo: did,
127 collection: 'app.bsky.graph.block',
128 limit: 50,
129 cursor: cursor,
130 );
131
132 final subjectDids = (response.data.records as List<dynamic>).map((r) => r.value['subject'] as String).toList();
133 final hydrated = await _hydrateProfiles(subjectDids);
134 return (
135 profiles: hydrated.profiles,
136 unavailable: hydrated.unavailable,
137 cursor: response.data.cursor as String?,
138 total: hydrated.profiles.length,
139 );
140 }
141
142 /// Returns a page of lists that [did] is a member of, along with the total
143 /// count and a cursor for the next page.
144 Future<({List<ListView> lists, String? cursor, int total})> getListsOn(String did, {String? cursor}) async {
145 final total = await _constellation.getBacklinksCount(did, 'app.bsky.graph.listitem:subject');
146 final result = await _constellation.getManyToMany(
147 did,
148 'app.bsky.graph.listitem:subject',
149 'list',
150 limit: _listsPageSize,
151 cursor: cursor,
152 );
153
154 final uniqueListUris = <String>{};
155 final listUris = <String>[];
156 for (final item in result.items) {
157 if (uniqueListUris.add(item.otherSubject)) {
158 listUris.add(item.otherSubject);
159 }
160 }
161
162 final lists = <ListView>[];
163 for (final uriString in listUris) {
164 try {
165 final uri = AtUri.parse(uriString);
166 final response = await _publicBluesky.graph.getList(list: uri, limit: 1);
167 lists.add(response.data.list as ListView);
168 } catch (error, stackTrace) {
169 log.w(
170 'skipping invalid or unavailable list in profile context: $uriString',
171 error: error,
172 stackTrace: stackTrace,
173 );
174 }
175 }
176
177 return (lists: lists, cursor: result.cursor, total: total);
178 }
179
180 /// Hydrates [dids] into [ProfileView] objects in batches of 25.
181 Future<({List<ProfileView> profiles, List<UnavailableProfileRef> unavailable})> _hydrateProfiles(
182 List<String> dids,
183 ) async {
184 final normalizedDids = _normalizeDids(dids);
185 if (normalizedDids.isEmpty) {
186 return (profiles: <ProfileView>[], unavailable: <UnavailableProfileRef>[]);
187 }
188
189 final allProfiles = <ProfileView>[];
190 final unavailable = <UnavailableProfileRef>[];
191 for (var i = 0; i < normalizedDids.length; i += 25) {
192 final batch = normalizedDids.sublist(i, (i + 25).clamp(0, normalizedDids.length));
193 final resolvedProfiles = <String, ProfileView>{};
194 log.d(
195 'ProfileContextRepository: blocked-by public batch ${i ~/ 25 + 1} size=${batch.length} dids=${batch.join(',')}',
196 );
197 try {
198 final response = await _publicBluesky.actor.getProfiles(actors: batch);
199 for (final profile in response.data.profiles as List<dynamic>) {
200 final converted = _asProfileView(profile);
201 if (converted != null) {
202 resolvedProfiles[converted.did] = converted;
203 }
204 }
205 } catch (error, stackTrace) {
206 log.w(
207 'ProfileContextRepository: blocked-by public batch failed, falling back to per-DID lookups for ${batch.length} actors',
208 error: error,
209 stackTrace: stackTrace,
210 );
211 }
212
213 for (final did in batch) {
214 final existing = resolvedProfiles[did];
215 if (existing != null) {
216 log.d('ProfileContextRepository: blocked-by public batch resolved $did');
217 allProfiles.add(existing);
218 continue;
219 }
220
221 try {
222 final response = await _publicBluesky.actor.getProfile(actor: did);
223 final converted = _asProfileView(response.data);
224 if (converted != null) {
225 log.d('ProfileContextRepository: blocked-by per-DID resolved $did');
226 allProfiles.add(converted);
227 } else {
228 const reason = 'Public profile lookup failed';
229 unavailable.add(UnavailableProfileRef(did: did, reason: reason));
230 log.w('ProfileContextRepository: blocked-by per-DID returned an unsupported profile shape for $did');
231 }
232 } catch (error, stackTrace) {
233 final reason = _publicProfileFailureReason(error);
234 unavailable.add(UnavailableProfileRef(did: did, reason: reason));
235 log.w(
236 'ProfileContextRepository: blocked-by per-DID failed for $did: $reason',
237 error: error,
238 stackTrace: stackTrace,
239 );
240 }
241 }
242 }
243 return (profiles: allProfiles, unavailable: unavailable);
244 }
245
246 Future<({List<String> dids, int total})> _collectBlockedByDids(String did) async {
247 try {
248 final dids = <String>[];
249 final seen = <String>{};
250 var total = 0;
251 String? cursor;
252 log.i('ProfileContextRepository: blocked-by using getDistinct for $did');
253
254 do {
255 final result = await _constellation.getDistinct(
256 did,
257 'app.bsky.graph.block:subject',
258 limit: _blockedByPageSize,
259 cursor: cursor,
260 );
261 final inputCursor = cursor;
262 if (total == 0) {
263 total = result.total;
264 log.i('ProfileContextRepository: blocked-by count for $did is $total');
265 }
266 for (final rawDid in result.dids) {
267 final normalizedDid = _normalizeDid(rawDid);
268 if (normalizedDid != null && seen.add(normalizedDid)) {
269 dids.add(normalizedDid);
270 }
271 }
272 cursor = result.cursor;
273 log.d(
274 'ProfileContextRepository: blocked-by getDistinct page cursorIn=${inputCursor ?? 'null'} records=${result.dids.length} uniqueTotal=${dids.length} cursorOut=${cursor ?? 'null'}',
275 );
276 } while (cursor != null);
277
278 log.i('ProfileContextRepository: blocked-by collected ${dids.length} unique DIDs from getDistinct for $did');
279 return (dids: dids, total: total);
280 } on ConstellationException catch (error) {
281 if (!_isNotFound(error)) rethrow;
282 log.w('ProfileContextRepository: blocked-by getDistinct returned 404 for $did, falling back to getBacklinks');
283
284 final dids = <String>[];
285 final seen = <String>{};
286 var total = 0;
287 String? cursor;
288
289 do {
290 final result = await _constellation.getBacklinks(
291 did,
292 'app.bsky.graph.block:subject',
293 limit: _blockedByPageSize,
294 cursor: cursor,
295 );
296 final inputCursor = cursor;
297 if (total == 0) {
298 total = result.total;
299 log.i('ProfileContextRepository: blocked-by count for $did is $total');
300 }
301 for (final record in result.records) {
302 final normalizedDid = _normalizeDid(record.did);
303 if (normalizedDid != null && seen.add(normalizedDid)) {
304 dids.add(normalizedDid);
305 }
306 }
307 cursor = result.cursor;
308 log.d(
309 'ProfileContextRepository: blocked-by getBacklinks page cursorIn=${inputCursor ?? 'null'} records=${result.records.length} uniqueTotal=${dids.length} cursorOut=${cursor ?? 'null'}',
310 );
311 } while (cursor != null);
312
313 log.i('ProfileContextRepository: blocked-by collected ${dids.length} unique DIDs from getBacklinks for $did');
314 return (dids: dids, total: total);
315 }
316 }
317
318 List<String> _normalizeDids(List<String> dids) {
319 final normalized = <String>[];
320 final seen = <String>{};
321 for (final did in dids) {
322 final value = _normalizeDid(did);
323 if (value != null && seen.add(value)) {
324 normalized.add(value);
325 }
326 }
327 return normalized;
328 }
329
330 String? _normalizeDid(String did) {
331 final trimmed = did.trim();
332 if (trimmed.isEmpty) return null;
333 return trimmed;
334 }
335
336 void _assertCurrentSessionRepoAccess({required String did, required String operation}) {
337 final normalizedDid = _normalizeDid(did)?.toLowerCase();
338 if (normalizedDid == null) {
339 throw ArgumentError.value(did, 'did', 'DID must not be empty');
340 }
341
342 final sessionDid = _currentSessionDid();
343 if (sessionDid == null) {
344 return;
345 }
346
347 if (normalizedDid != sessionDid) {
348 throw StateError(
349 'ProfileContextRepository.$operation supports only current-session repo reads: did=$normalizedDid sessionDid=$sessionDid',
350 );
351 }
352 }
353
354 String? _currentSessionDid() {
355 try {
356 final sessionDid = (_bluesky.session?.did as String?)?.trim().toLowerCase();
357 if (sessionDid != null && sessionDid.isNotEmpty) {
358 return sessionDid;
359 }
360 } catch (_) {}
361
362 try {
363 final oauthDid = (_bluesky.oAuthSession?.sub as String?)?.trim().toLowerCase();
364 if (oauthDid != null && oauthDid.isNotEmpty) {
365 return oauthDid;
366 }
367 } catch (_) {}
368
369 return null;
370 }
371
372 String _publicProfileFailureReason(Object error) {
373 final message = error.toString();
374 final lowerMessage = message.toLowerCase();
375 if (message.contains('AccountTakedown') || lowerMessage.contains('account has been suspended')) {
376 return 'Suspended account';
377 }
378 if (message.contains('HTTP 404') || lowerMessage.contains('not found')) {
379 return 'Profile unavailable';
380 }
381 return 'Public profile lookup failed';
382 }
383
384 ProfileView? _asProfileView(dynamic profile) {
385 if (profile is ProfileView) {
386 return profile;
387 }
388
389 if (profile is ProfileViewDetailed) {
390 return ProfileView(
391 did: profile.did,
392 handle: profile.handle,
393 displayName: profile.displayName,
394 pronouns: profile.pronouns,
395 description: profile.description,
396 avatar: profile.avatar,
397 associated: profile.associated,
398 indexedAt: profile.indexedAt,
399 createdAt: profile.createdAt,
400 viewer: profile.viewer,
401 labels: profile.labels,
402 verification: profile.verification,
403 status: profile.status,
404 debug: profile.debug,
405 );
406 }
407
408 return null;
409 }
410
411 bool _isNotFound(ConstellationException error) => error.message.startsWith('HTTP 404');
412}