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