mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

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

at main 412 lines 15 kB view raw
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}