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 660 lines 20 kB view raw
1import 'dart:async'; 2 3import 'package:atproto/atproto.dart'; 4import 'package:atproto/com_atproto_identity_resolvehandle.dart'; 5import 'package:atproto/com_atproto_repo_describerepo.dart'; 6import 'package:atproto/com_atproto_repo_getrecord.dart'; 7import 'package:atproto/com_atproto_repo_listrecords.dart'; 8import 'package:atproto_core/atproto_core.dart'; 9import 'package:bluesky/app_bsky_actor_defs.dart'; 10import 'package:bluesky/app_bsky_actor_searchactorstypeahead.dart'; 11import 'package:equatable/equatable.dart'; 12import 'package:flutter_bloc/flutter_bloc.dart'; 13import 'package:lazurite/core/logging/app_logger.dart'; 14import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; 15import 'package:lazurite/core/network/atproto_host_resolver.dart'; 16 17part 'dev_tools_state.dart'; 18 19abstract interface class DevToolsRepository { 20 Future<IdentityResolveHandleOutput> resolveHandle({required String handle}); 21 22 Future<RepoDescribeRepoOutput> describeRepo({required String repo, String? serviceHost}); 23 24 Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 8}); 25 26 Future<RepoListRecordsOutput> listRecords({ 27 required String repo, 28 required String collection, 29 int? limit, 30 String? cursor, 31 bool? reverse, 32 String? serviceHost, 33 }); 34 35 Future<RepoGetRecordOutput> getRecord({ 36 required String repo, 37 required String collection, 38 required String rkey, 39 String? serviceHost, 40 }); 41} 42 43final class AtprotoDevToolsRepository implements DevToolsRepository { 44 const AtprotoDevToolsRepository({required ATProto atproto}) : _atproto = atproto; 45 46 static const _searchActorsTypeaheadNsid = NSID('app.bsky.actor.searchActorsTypeahead'); 47 48 final ATProto _atproto; 49 50 @override 51 Future<IdentityResolveHandleOutput> resolveHandle({required String handle}) async { 52 final response = await _atproto.identity.resolveHandle(handle: handle); 53 return response.data; 54 } 55 56 @override 57 Future<RepoDescribeRepoOutput> describeRepo({required String repo, String? serviceHost}) async { 58 final response = await _atproto.repo.describeRepo(repo: repo, $service: serviceHost); 59 return response.data; 60 } 61 62 @override 63 Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 8}) async { 64 final normalizedQuery = query.trim().replaceFirst(RegExp(r'^@+'), ''); 65 if (normalizedQuery.isEmpty) { 66 return const []; 67 } 68 69 final response = await _atproto.get( 70 _searchActorsTypeaheadNsid, 71 parameters: {'q': normalizedQuery, 'limit': limit}, 72 to: const ActorSearchActorsTypeaheadOutputConverter().fromJson, 73 ); 74 return response.data.actors; 75 } 76 77 @override 78 Future<RepoListRecordsOutput> listRecords({ 79 required String repo, 80 required String collection, 81 int? limit, 82 String? cursor, 83 bool? reverse, 84 String? serviceHost, 85 }) async { 86 final response = await _atproto.repo.listRecords( 87 repo: repo, 88 collection: collection, 89 limit: limit, 90 cursor: cursor, 91 reverse: reverse, 92 $service: serviceHost, 93 ); 94 return response.data; 95 } 96 97 @override 98 Future<RepoGetRecordOutput> getRecord({ 99 required String repo, 100 required String collection, 101 required String rkey, 102 String? serviceHost, 103 }) async { 104 final response = await _atproto.repo.getRecord( 105 repo: repo, 106 collection: collection, 107 rkey: rkey, 108 $service: serviceHost, 109 ); 110 return response.data; 111 } 112} 113 114class DevToolsCubit extends Cubit<DevToolsState> { 115 DevToolsCubit({ATProto? atproto, DevToolsRepository? repository, ActorRepositoryServiceResolver? actorRepoResolver}) 116 : assert(atproto != null || repository != null, 'Provide either atproto or repository'), 117 _repository = repository ?? AtprotoDevToolsRepository(atproto: atproto!), 118 _actorRepoResolver = actorRepoResolver ?? (atproto == null ? null : ActorRepositoryServiceResolver()), 119 super(const DevToolsState()); 120 121 static const _pageSize = 50; 122 static const _countPageSize = 100; 123 124 final DevToolsRepository _repository; 125 final ActorRepositoryServiceResolver? _actorRepoResolver; 126 int _resolveRequestId = 0; 127 int _collectionRequestId = 0; 128 int _recordRequestId = 0; 129 int _typeaheadRequestId = 0; 130 131 Future<void> resolve(String input) async { 132 final query = _normalizeInputForResolve(input); 133 if (query.isEmpty) { 134 clearInput(); 135 return; 136 } 137 138 final resolveRequestId = _beginResolveRequest(); 139 emit(const DevToolsState(status: DevToolsStatus.loading)); 140 141 try { 142 if (query.startsWith('at://')) { 143 await _resolveAtUri(query, resolveRequestId); 144 return; 145 } 146 147 final identity = await _resolveIdentity(query); 148 if (!_isActiveResolveRequest(resolveRequestId)) { 149 return; 150 } 151 152 final repo = await _repository.describeRepo(repo: identity.did, serviceHost: identity.pdsHost); 153 if (!_isActiveResolveRequest(resolveRequestId)) { 154 return; 155 } 156 157 emit( 158 _buildRepoState( 159 repo: repo, 160 did: identity.did, 161 handle: identity.handle, 162 repoServiceHost: identity.pdsHost, 163 status: DevToolsStatus.repoLoaded, 164 ), 165 ); 166 unawaited( 167 _loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did, repoServiceHost: identity.pdsHost), 168 ); 169 } catch (error, stackTrace) { 170 log.e('DevToolsCubit: Failed to resolve repo', error: error, stackTrace: stackTrace); 171 if (_isActiveResolveRequest(resolveRequestId)) { 172 emit(state.copyWith(status: DevToolsStatus.error, errorMessage: _formatError(error))); 173 } 174 } 175 } 176 177 Future<void> queryTypeahead(String input) async { 178 final query = input.trim(); 179 if (!query.startsWith('@')) { 180 clearTypeahead(); 181 return; 182 } 183 184 final normalizedQuery = query.replaceFirst(RegExp(r'^@+'), ''); 185 if (normalizedQuery.isEmpty) { 186 clearTypeahead(); 187 return; 188 } 189 190 final typeaheadRequestId = _beginTypeaheadRequest(); 191 emit(state.copyWith(isTypeaheadLoading: true, typeaheadActors: const [])); 192 193 try { 194 final actors = await _repository.searchActorsTypeahead(query: normalizedQuery); 195 if (!_isActiveTypeaheadRequest(typeaheadRequestId)) { 196 return; 197 } 198 199 emit(state.copyWith(typeaheadActors: actors, isTypeaheadLoading: false)); 200 } catch (error, stackTrace) { 201 log.w('DevToolsCubit: Failed to fetch handle typeahead', error: error, stackTrace: stackTrace); 202 if (_isActiveTypeaheadRequest(typeaheadRequestId)) { 203 emit(state.copyWith(typeaheadActors: const [], isTypeaheadLoading: false)); 204 } 205 } 206 } 207 208 void clearTypeahead() { 209 final hadTypeahead = state.typeaheadActors.isNotEmpty || state.isTypeaheadLoading; 210 _beginTypeaheadRequest(); 211 if (!hadTypeahead) { 212 return; 213 } 214 emit(state.copyWith(typeaheadActors: const [], isTypeaheadLoading: false)); 215 } 216 217 Future<void> loadCollection(String collection) async { 218 if (state.did == null) return; 219 220 final collectionRequestId = _beginCollectionRequest(); 221 emit(state.copyWith(isCollectionLoading: true, isRecordLoading: false, errorMessage: null)); 222 223 try { 224 final response = await _repository.listRecords( 225 repo: state.did!, 226 collection: collection, 227 limit: _pageSize, 228 serviceHost: state.repoServiceHost, 229 ); 230 if (!_isActiveCollectionRequest(collectionRequestId)) { 231 return; 232 } 233 234 emit( 235 state.copyWith( 236 status: DevToolsStatus.collectionLoaded, 237 selectedCollection: collection, 238 records: response.records, 239 recordsCursor: response.cursor, 240 selectedRecord: null, 241 isCollectionLoading: false, 242 isRecordLoading: false, 243 errorMessage: null, 244 ), 245 ); 246 } catch (error, stackTrace) { 247 log.e('DevToolsCubit: Failed to load collection', error: error, stackTrace: stackTrace); 248 if (_isActiveCollectionRequest(collectionRequestId)) { 249 emit(state.copyWith(isCollectionLoading: false, errorMessage: _formatError(error))); 250 } 251 } 252 } 253 254 Future<void> loadMoreRecords() async { 255 if (state.did == null || state.selectedCollection == null || state.recordsCursor == null) return; 256 257 emit(state.copyWith(status: DevToolsStatus.loadingMore, errorMessage: null)); 258 259 final activeCollectionRequestId = _collectionRequestId; 260 try { 261 final response = await _repository.listRecords( 262 repo: state.did!, 263 collection: state.selectedCollection!, 264 cursor: state.recordsCursor, 265 limit: _pageSize, 266 serviceHost: state.repoServiceHost, 267 ); 268 if (!_isActiveCollectionRequest(activeCollectionRequestId)) { 269 return; 270 } 271 272 emit( 273 state.copyWith( 274 status: DevToolsStatus.collectionLoaded, 275 records: [...?state.records, ...response.records], 276 recordsCursor: response.cursor, 277 errorMessage: null, 278 ), 279 ); 280 } catch (error, stackTrace) { 281 log.e('DevToolsCubit: Failed to load more records', error: error, stackTrace: stackTrace); 282 if (_isActiveCollectionRequest(activeCollectionRequestId)) { 283 emit( 284 state.copyWith( 285 status: DevToolsStatus.collectionLoaded, 286 isCollectionLoading: false, 287 isRecordLoading: false, 288 errorMessage: _formatError(error), 289 ), 290 ); 291 } 292 } 293 } 294 295 Future<void> loadRecord(RepoListRecordsRecord record) async { 296 if (state.did == null) return; 297 298 final recordRequestId = _beginRecordRequest(); 299 emit(state.copyWith(isRecordLoading: true, errorMessage: null)); 300 301 try { 302 final resolvedRecord = await _repository.getRecord( 303 repo: state.did!, 304 collection: record.uri.collection.toString(), 305 rkey: record.uri.rkey, 306 serviceHost: state.repoServiceHost, 307 ); 308 if (!_isActiveRecordRequest(recordRequestId)) { 309 return; 310 } 311 312 emit( 313 state.copyWith( 314 status: DevToolsStatus.recordLoaded, 315 selectedRecord: RecordInfo( 316 uri: resolvedRecord.uri.toString(), 317 cid: resolvedRecord.cid, 318 value: resolvedRecord.value, 319 ), 320 isRecordLoading: false, 321 errorMessage: null, 322 ), 323 ); 324 } catch (error, stackTrace) { 325 log.e('DevToolsCubit: Failed to load record', error: error, stackTrace: stackTrace); 326 if (_isActiveRecordRequest(recordRequestId)) { 327 emit(state.copyWith(isRecordLoading: false, errorMessage: _formatError(error))); 328 } 329 } 330 } 331 332 void goBackToCollection() { 333 _recordRequestId++; 334 if (state.selectedCollection != null) { 335 emit( 336 state.copyWith( 337 status: DevToolsStatus.collectionLoaded, 338 selectedRecord: null, 339 isCollectionLoading: false, 340 isRecordLoading: false, 341 ), 342 ); 343 } else { 344 emit( 345 state.copyWith( 346 status: DevToolsStatus.repoLoaded, 347 selectedCollection: null, 348 records: null, 349 recordsCursor: null, 350 selectedRecord: null, 351 isCollectionLoading: false, 352 isRecordLoading: false, 353 ), 354 ); 355 } 356 } 357 358 void goBackToRepo() { 359 _collectionRequestId++; 360 _recordRequestId++; 361 emit( 362 state.copyWith( 363 status: DevToolsStatus.repoLoaded, 364 selectedCollection: null, 365 records: null, 366 recordsCursor: null, 367 selectedRecord: null, 368 isCollectionLoading: false, 369 isRecordLoading: false, 370 ), 371 ); 372 } 373 374 void clearInput() { 375 _beginResolveRequest(); 376 emit(const DevToolsState()); 377 } 378 379 int _beginResolveRequest() { 380 _resolveRequestId++; 381 _collectionRequestId++; 382 _recordRequestId++; 383 _typeaheadRequestId++; 384 return _resolveRequestId; 385 } 386 387 int _beginCollectionRequest() { 388 _collectionRequestId++; 389 _recordRequestId++; 390 return _collectionRequestId; 391 } 392 393 int _beginRecordRequest() { 394 _recordRequestId++; 395 return _recordRequestId; 396 } 397 398 int _beginTypeaheadRequest() { 399 _typeaheadRequestId++; 400 return _typeaheadRequestId; 401 } 402 403 bool _isActiveResolveRequest(int requestId) => requestId == _resolveRequestId; 404 405 bool _isActiveCollectionRequest(int requestId) => requestId == _collectionRequestId; 406 407 bool _isActiveRecordRequest(int requestId) => requestId == _recordRequestId; 408 409 bool _isActiveTypeaheadRequest(int requestId) => requestId == _typeaheadRequestId; 410 411 Future<void> _resolveAtUri(String input, int resolveRequestId) async { 412 final atUri = _parseAtUri(input); 413 final identity = await _resolveIdentity(atUri.hostname); 414 if (!_isActiveResolveRequest(resolveRequestId)) { 415 return; 416 } 417 418 final repo = await _repository.describeRepo(repo: identity.did, serviceHost: identity.pdsHost); 419 if (!_isActiveResolveRequest(resolveRequestId)) { 420 return; 421 } 422 423 final collection = _collectionFromAtUri(atUri); 424 final rkey = _rkeyFromAtUri(atUri); 425 426 if (collection == null) { 427 emit( 428 _buildRepoState( 429 repo: repo, 430 did: identity.did, 431 handle: identity.handle, 432 repoServiceHost: identity.pdsHost, 433 status: DevToolsStatus.repoLoaded, 434 ), 435 ); 436 unawaited( 437 _loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did, repoServiceHost: identity.pdsHost), 438 ); 439 return; 440 } 441 442 final records = await _repository.listRecords( 443 repo: identity.did, 444 collection: collection, 445 limit: _pageSize, 446 serviceHost: identity.pdsHost, 447 ); 448 if (!_isActiveResolveRequest(resolveRequestId)) { 449 return; 450 } 451 452 if (rkey == null) { 453 emit( 454 _buildRepoState( 455 repo: repo, 456 did: identity.did, 457 handle: identity.handle, 458 repoServiceHost: identity.pdsHost, 459 status: DevToolsStatus.collectionLoaded, 460 selectedCollection: collection, 461 records: records.records, 462 recordsCursor: records.cursor, 463 ), 464 ); 465 unawaited( 466 _loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did, repoServiceHost: identity.pdsHost), 467 ); 468 return; 469 } 470 471 final record = await _repository.getRecord( 472 repo: identity.did, 473 collection: collection, 474 rkey: rkey, 475 serviceHost: identity.pdsHost, 476 ); 477 if (!_isActiveResolveRequest(resolveRequestId)) { 478 return; 479 } 480 481 emit( 482 _buildRepoState( 483 repo: repo, 484 did: identity.did, 485 handle: identity.handle, 486 repoServiceHost: identity.pdsHost, 487 status: DevToolsStatus.recordLoaded, 488 selectedCollection: collection, 489 records: records.records, 490 recordsCursor: records.cursor, 491 selectedRecord: RecordInfo(uri: record.uri.toString(), cid: record.cid, value: record.value), 492 ), 493 ); 494 unawaited( 495 _loadCollectionCounts(resolveRequestId: resolveRequestId, did: identity.did, repoServiceHost: identity.pdsHost), 496 ); 497 } 498 499 Future<({String did, String? handle, String pdsHost})> _resolveIdentity(String input) async { 500 final normalizedInput = _normalizeInputForResolve(input); 501 final resolver = _actorRepoResolver; 502 if (resolver != null) { 503 final resolution = await resolver.resolve(normalizedInput); 504 return ( 505 did: resolution.did, 506 handle: normalizedInput.startsWith('did:') ? null : normalizedInput, 507 pdsHost: resolution.pdsHost, 508 ); 509 } 510 511 final did = normalizedInput.startsWith('did:') 512 ? normalizedInput 513 : (await _repository.resolveHandle(handle: normalizedInput)).did; 514 final repo = await _repository.describeRepo(repo: did); 515 final pdsHost = extractAtprotoPdsHostFromDidDoc(repo.didDoc); 516 if (pdsHost == null || pdsHost.isEmpty) { 517 throw StateError('Unable to resolve PDS host for repo: $did'); 518 } 519 return (did: did, handle: normalizedInput.startsWith('did:') ? null : normalizedInput, pdsHost: pdsHost); 520 } 521 522 AtUri _parseAtUri(String input) { 523 try { 524 return AtUri.parse(input); 525 } on FormatException { 526 throw const FormatException('Invalid AT-URI'); 527 } 528 } 529 530 String? _collectionFromAtUri(AtUri atUri) { 531 final segments = atUri.pathname.split('/').where((segment) => segment.isNotEmpty).toList(); 532 if (segments.isEmpty) { 533 return null; 534 } 535 536 return segments.first; 537 } 538 539 String? _rkeyFromAtUri(AtUri atUri) { 540 final segments = atUri.pathname.split('/').where((segment) => segment.isNotEmpty).toList(); 541 if (segments.length < 2) { 542 return null; 543 } 544 545 return segments[1]; 546 } 547 548 DevToolsState _buildRepoState({ 549 required RepoDescribeRepoOutput repo, 550 required String did, 551 required String? handle, 552 required String repoServiceHost, 553 required DevToolsStatus status, 554 String? selectedCollection, 555 List<RepoListRecordsRecord>? records, 556 String? recordsCursor, 557 RecordInfo? selectedRecord, 558 }) { 559 return DevToolsState( 560 status: status, 561 did: did, 562 handle: handle ?? repo.handle, 563 repoServiceHost: repoServiceHost, 564 repoHandle: repo.handle, 565 collections: repo.collections.map(CollectionSummary.new).toList(growable: false), 566 isCollectionCountsLoading: repo.collections.isNotEmpty, 567 selectedCollection: selectedCollection, 568 records: records, 569 recordsCursor: recordsCursor, 570 selectedRecord: selectedRecord, 571 isCollectionLoading: false, 572 isRecordLoading: false, 573 ); 574 } 575 576 Future<void> _loadCollectionCounts({ 577 required int resolveRequestId, 578 required String did, 579 required String repoServiceHost, 580 }) async { 581 if (state.collections.isEmpty) { 582 if (_isActiveResolveRequest(resolveRequestId)) { 583 emit(state.copyWith(isCollectionCountsLoading: false)); 584 } 585 return; 586 } 587 588 final countsByCollection = <String, int>{}; 589 for (final collection in state.collections) { 590 if (!_isActiveResolveRequest(resolveRequestId)) { 591 return; 592 } 593 594 countsByCollection[collection.name] = await _countRecords( 595 did: did, 596 collection: collection.name, 597 repoServiceHost: repoServiceHost, 598 ); 599 if (!_isActiveResolveRequest(resolveRequestId)) { 600 return; 601 } 602 603 emit( 604 state.copyWith( 605 collections: _mergeCollectionCounts(state.collections, countsByCollection), 606 isCollectionCountsLoading: countsByCollection.length < state.collections.length, 607 ), 608 ); 609 } 610 } 611 612 Future<int> _countRecords({required String did, required String collection, required String repoServiceHost}) async { 613 var total = 0; 614 String? cursor; 615 616 do { 617 final response = await _repository.listRecords( 618 repo: did, 619 collection: collection, 620 limit: _countPageSize, 621 cursor: cursor, 622 serviceHost: repoServiceHost, 623 ); 624 total += response.records.length; 625 cursor = response.cursor; 626 } while (cursor != null && cursor.isNotEmpty); 627 628 return total; 629 } 630 631 List<CollectionSummary> _mergeCollectionCounts( 632 List<CollectionSummary> collections, 633 Map<String, int> countsByCollection, 634 ) { 635 return collections 636 .map( 637 (collection) => CollectionSummary( 638 collection.name, 639 recordCount: countsByCollection[collection.name] ?? collection.recordCount, 640 ), 641 ) 642 .toList(growable: false); 643 } 644 645 String _formatError(Object error) { 646 if (error is FormatException) { 647 return error.message; 648 } 649 650 return error.toString(); 651 } 652 653 String _normalizeInputForResolve(String input) { 654 final query = input.trim(); 655 if (query.startsWith('@') && !query.startsWith('at://')) { 656 return query.replaceFirst(RegExp(r'^@+'), ''); 657 } 658 return query; 659 } 660}