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 1083 lines 41 kB view raw
1import 'dart:async'; 2import 'dart:convert'; 3import 'dart:io'; 4import 'dart:ui' as ui; 5 6import 'package:atproto_core/atproto_core.dart' show AtUri, Blob, BlobRef, XRPCException; 7import 'package:bluesky/bluesky.dart'; 8import 'package:bluesky/app_bsky_video_defs.dart' show KnownJobStatusState; 9import 'package:bluesky_text/bluesky_text.dart'; 10import 'package:characters/characters.dart'; 11import 'package:drift/drift.dart'; 12import 'package:equatable/equatable.dart'; 13import 'package:flutter_bloc/flutter_bloc.dart'; 14import 'package:lazurite/core/database/app_database.dart'; 15import 'package:lazurite/core/logging/app_logger.dart'; 16import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; 17import 'package:lazurite/core/scheduler/post_scheduler.dart'; 18import 'package:lazurite/features/compose/data/link_preview_service.dart'; 19 20part 'compose_event.dart'; 21part 'compose_state.dart'; 22 23const int kMaxGraphemes = 300; 24const int kMaxImages = 4; 25 26/// 1 MB 27const int kMaxImageBytes = 1 * 1024 * 1024; 28 29/// 100 MB 30const int kMaxVideoBytes = 100 * 1024 * 1024; 31const _kVideoJobPollInterval = Duration(seconds: 2); 32const _kVideoJobPollTimeout = Duration(minutes: 5); 33 34class ComposeBloc extends Bloc<ComposeEvent, ComposeState> { 35 ComposeBloc({required ComposeRepository composeRepository, required AppDatabase database, required String accountDid}) 36 : _composeRepository = composeRepository, 37 _database = database, 38 _accountDid = accountDid, 39 super(const ComposeState.ready()) { 40 on<TextChanged>(_onTextChanged); 41 on<MediaAttached>(_onMediaAttached); 42 on<MediaRemoved>(_onMediaRemoved); 43 on<AltTextUpdated>(_onAltTextUpdated); 44 on<VideoAttached>(_onVideoAttached); 45 on<VideoRemoved>(_onVideoRemoved); 46 on<VideoAltTextUpdated>(_onVideoAltTextUpdated); 47 on<DraftSaved>(_onDraftSaved); 48 on<DraftLoaded>(_onDraftLoaded); 49 on<DraftsRequested>(_onDraftsRequested); 50 on<DraftDeleted>(_onDraftDeleted); 51 on<PostScheduled>(_onPostScheduled); 52 on<ScheduleCleared>(_onScheduleCleared); 53 on<PostSubmitted>(_onPostSubmitted); 54 on<ReplyContextSet>(_onReplyContextSet); 55 on<ReplyContextCleared>(_onReplyContextCleared); 56 on<QuoteContextSet>(_onQuoteContextSet); 57 on<QuoteContextCleared>(_onQuoteContextCleared); 58 on<EditContextSet>(_onEditContextSet); 59 } 60 61 final ComposeRepository _composeRepository; 62 final AppDatabase _database; 63 final String _accountDid; 64 65 Future<void> _onTextChanged(TextChanged event, Emitter<ComposeState> emit) async { 66 final text = event.text; 67 if (text == state.text) { 68 return; 69 } 70 final graphemeCount = text.characters.length; 71 final isOverLimit = graphemeCount > kMaxGraphemes; 72 final isEmpty = text.trim().isEmpty && state.mediaAttachments.isEmpty && state.videoAttachment == null; 73 74 emit( 75 state.copyWith( 76 text: text, 77 graphemeCount: graphemeCount, 78 isOverLimit: isOverLimit, 79 isEmpty: isEmpty, 80 canSubmit: !isOverLimit && !isEmpty && !(state.videoAttachment?.isActive ?? false), 81 isDraftDirty: true, 82 ), 83 ); 84 } 85 86 Future<void> _onMediaAttached(MediaAttached event, Emitter<ComposeState> emit) async { 87 if (!state.canAddMoreMedia) return; 88 89 final attachments = List<MediaAttachment>.from(state.mediaAttachments) 90 ..add(MediaAttachment(localPath: event.path, width: event.width, height: event.height)); 91 final isEmpty = state.text.trim().isEmpty && attachments.isEmpty; 92 93 emit( 94 state.copyWith( 95 mediaAttachments: attachments, 96 isEmpty: isEmpty, 97 canSubmit: !state.isOverLimit && !isEmpty, 98 isDraftDirty: true, 99 ), 100 ); 101 } 102 103 Future<void> _onMediaRemoved(MediaRemoved event, Emitter<ComposeState> emit) async { 104 if (event.index < 0 || event.index >= state.mediaAttachments.length) return; 105 106 final attachments = List<MediaAttachment>.from(state.mediaAttachments)..removeAt(event.index); 107 final isEmpty = state.text.trim().isEmpty && attachments.isEmpty && state.videoAttachment == null; 108 109 emit( 110 state.copyWith( 111 mediaAttachments: attachments, 112 isEmpty: isEmpty, 113 canSubmit: !state.isOverLimit && !isEmpty, 114 isDraftDirty: true, 115 ), 116 ); 117 } 118 119 Future<void> _onAltTextUpdated(AltTextUpdated event, Emitter<ComposeState> emit) async { 120 if (event.index < 0 || event.index >= state.mediaAttachments.length) return; 121 122 final attachments = List<MediaAttachment>.from(state.mediaAttachments); 123 attachments[event.index] = attachments[event.index].copyWith(altText: event.altText); 124 emit(state.copyWith(mediaAttachments: attachments)); 125 } 126 127 Future<void> _onVideoAttached(VideoAttached event, Emitter<ComposeState> emit) async { 128 if (!state.canAddVideo) return; 129 130 final file = File(event.path); 131 if (!file.existsSync()) return; 132 133 final sizeBytes = file.lengthSync(); 134 if (sizeBytes > kMaxVideoBytes) { 135 final mb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); 136 _emitError(emit, 'Video is $mb MB — exceeds the 100 MB limit.'); 137 return; 138 } 139 140 final pendingVideo = VideoAttachment(localPath: event.path, status: VideoUploadStatus.checkingLimits); 141 emit(state.copyWith(videoAttachment: pendingVideo, canSubmit: false, isEmpty: false)); 142 143 try { 144 final limits = await _composeRepository.getUploadLimits(); 145 if (limits != null && !limits.canUpload) { 146 _setVideoError(emit, limits.message ?? 'Daily video upload limit reached.'); 147 return; 148 } 149 150 emit(state.copyWith(videoAttachment: pendingVideo.copyWith(status: VideoUploadStatus.uploading))); 151 final bytes = await file.readAsBytes(); 152 final jobId = await _composeRepository.uploadVideo(bytes); 153 if (jobId == null) { 154 _setVideoError(emit, 'Upload failed — please try again.'); 155 return; 156 } 157 158 emit( 159 state.copyWith( 160 videoAttachment: pendingVideo.copyWith(status: VideoUploadStatus.processing, jobId: jobId), 161 ), 162 ); 163 final blob = await _pollVideoJob(jobId, emit); 164 if (blob == null) return; 165 166 final readyVideo = VideoAttachment( 167 localPath: event.path, 168 status: VideoUploadStatus.ready, 169 uploadProgress: 100, 170 blob: blob, 171 altText: state.videoAttachment?.altText ?? '', 172 jobId: jobId, 173 ); 174 final isEmpty = state.text.trim().isEmpty && state.mediaAttachments.isEmpty; 175 emit(state.copyWith(videoAttachment: readyVideo, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty)); 176 } catch (e, stackTrace) { 177 log.e('Video upload failed', error: e, stackTrace: stackTrace); 178 _setVideoError(emit, 'Upload failed: $e'); 179 } 180 } 181 182 Future<Blob?> _pollVideoJob(String jobId, Emitter<ComposeState> emit) async { 183 final deadline = DateTime.now().add(_kVideoJobPollTimeout); 184 185 while (DateTime.now().isBefore(deadline)) { 186 await Future.delayed(_kVideoJobPollInterval); 187 try { 188 final status = await _composeRepository.getJobStatus(jobId); 189 if (status == null) continue; 190 191 final progress = status.progress ?? 0; 192 final current = state.videoAttachment; 193 if (current != null) { 194 emit(state.copyWith(videoAttachment: current.copyWith(uploadProgress: progress))); 195 } 196 197 final knownState = status.state.knownValue; 198 if (knownState == KnownJobStatusState.jOB_STATE_COMPLETED && status.blob != null) { 199 return status.blob; 200 } 201 if (knownState == KnownJobStatusState.jOB_STATE_FAILED) { 202 _setVideoError(emit, status.error ?? 'Video processing failed.'); 203 return null; 204 } 205 } catch (e) { 206 log.w('Video job poll error (retrying)', error: e); 207 } 208 } 209 210 _setVideoError(emit, 'Video processing timed out.'); 211 return null; 212 } 213 214 void _setVideoError(Emitter<ComposeState> emit, String message) { 215 final current = state.videoAttachment; 216 if (current != null) { 217 emit( 218 state.copyWith( 219 videoAttachment: current.copyWith(status: VideoUploadStatus.error, errorMessage: message), 220 ), 221 ); 222 } 223 } 224 225 Future<void> _onVideoRemoved(VideoRemoved event, Emitter<ComposeState> emit) async { 226 final isEmpty = state.text.trim().isEmpty && state.mediaAttachments.isEmpty; 227 emit(state.copyWith(videoAttachment: null, isEmpty: isEmpty, canSubmit: !state.isOverLimit && !isEmpty)); 228 } 229 230 Future<void> _onVideoAltTextUpdated(VideoAltTextUpdated event, Emitter<ComposeState> emit) async { 231 final updated = state.videoAttachment?.copyWith(altText: event.altText); 232 if (updated != null) emit(state.copyWith(videoAttachment: updated)); 233 } 234 235 Future<void> _onDraftSaved(DraftSaved event, Emitter<ComposeState> emit) async { 236 emit(state.copyWith(isSavingDraft: true)); 237 try { 238 final embedJson = _buildEmbedJson(); 239 final draft = DraftsCompanion( 240 id: state.draftId != null ? Value(state.draftId!) : const Value.absent(), 241 accountDid: Value(_accountDid), 242 content: Value(state.text), 243 replyUri: state.replyParentUri != null ? Value(state.replyParentUri!) : const Value.absent(), 244 replyCid: state.replyParentCid != null ? Value(state.replyParentCid!) : const Value.absent(), 245 rootUri: state.replyRootUri != null ? Value(state.replyRootUri!) : const Value.absent(), 246 rootCid: state.replyRootCid != null ? Value(state.replyRootCid!) : const Value.absent(), 247 embedJson: embedJson != null ? Value(jsonEncode(embedJson)) : const Value.absent(), 248 mediaPaths: state.mediaAttachments.isNotEmpty 249 ? Value(jsonEncode(state.mediaAttachments.map((m) => m.localPath).toList())) 250 : const Value.absent(), 251 scheduledAt: state.scheduledAt != null ? Value(state.scheduledAt!) : const Value.absent(), 252 updatedAt: Value(DateTime.now()), 253 ); 254 final id = await _database.saveDraft(draft); 255 emit(state.copyWith(draftId: id, isSavingDraft: false, isDraftDirty: false)); 256 } catch (e, stackTrace) { 257 log.e('Failed to save draft', error: e, stackTrace: stackTrace); 258 emit(state.copyWith(isSavingDraft: false)); 259 } 260 } 261 262 Future<void> _onDraftLoaded(DraftLoaded event, Emitter<ComposeState> emit) async { 263 try { 264 final draft = await _database.getDraft(event.draftId); 265 if (draft == null) return; 266 267 List<MediaAttachment> attachments = []; 268 269 if (draft.embedJson != null) { 270 try { 271 final decoded = jsonDecode(draft.embedJson!) as Map<String, dynamic>; 272 final type = decoded['type'] as String?; 273 if (type == 'images') { 274 final paths = decoded['paths'] as List<dynamic>? ?? []; 275 final alts = decoded['altTexts'] as List<dynamic>? ?? []; 276 attachments = paths.asMap().entries.where((e) => File(e.value as String).existsSync()).map((e) { 277 return MediaAttachment( 278 localPath: e.value as String, 279 altText: e.key < alts.length ? (alts[e.key] as String? ?? '') : '', 280 ); 281 }).toList(); 282 } 283 } catch (e) { 284 log.w('Failed to parse embedJson from draft', error: e); 285 } 286 } 287 288 if (attachments.isEmpty && draft.mediaPaths != null) { 289 try { 290 final paths = jsonDecode(draft.mediaPaths!) as List<dynamic>; 291 attachments = paths 292 .whereType<String>() 293 .where((path) => File(path).existsSync()) 294 .map((path) => MediaAttachment(localPath: path)) 295 .toList(); 296 } catch (e) { 297 log.w('Failed to parse mediaPaths from draft', error: e); 298 } 299 } 300 301 final text = draft.content; 302 final graphemeCount = text.characters.length; 303 final isOverLimit = graphemeCount > kMaxGraphemes; 304 final isEmpty = text.trim().isEmpty && attachments.isEmpty; 305 306 emit( 307 ComposeState.ready( 308 text: text, 309 mediaAttachments: attachments, 310 draftId: draft.id, 311 scheduledAt: draft.scheduledAt, 312 replyParentUri: draft.replyUri, 313 replyParentCid: draft.replyCid, 314 replyRootUri: draft.rootUri, 315 replyRootCid: draft.rootCid, 316 isDraftDirty: false, 317 ).copyWith( 318 graphemeCount: graphemeCount, 319 isOverLimit: isOverLimit, 320 isEmpty: isEmpty, 321 canSubmit: !isOverLimit && !isEmpty, 322 ), 323 ); 324 } catch (e, stackTrace) { 325 log.e('Failed to load draft', error: e, stackTrace: stackTrace); 326 } 327 } 328 329 Future<void> _onDraftsRequested(DraftsRequested event, Emitter<ComposeState> emit) async { 330 emit(state.copyWith(isLoadingDrafts: true)); 331 try { 332 final drafts = await _database.getDrafts(_accountDid); 333 emit(state.copyWith(drafts: drafts, isLoadingDrafts: false)); 334 } catch (e, stackTrace) { 335 log.e('Failed to load drafts', error: e, stackTrace: stackTrace); 336 emit(state.copyWith(isLoadingDrafts: false)); 337 } 338 } 339 340 Future<void> _onDraftDeleted(DraftDeleted event, Emitter<ComposeState> emit) async { 341 try { 342 await _database.deleteDraft(event.draftId); 343 final drafts = List<DraftEntry>.from(state.drafts)..removeWhere((d) => d.id == event.draftId); 344 emit(state.copyWith(drafts: drafts)); 345 } catch (e, stackTrace) { 346 log.e('Failed to delete draft', error: e, stackTrace: stackTrace); 347 } 348 } 349 350 Future<void> _onPostScheduled(PostScheduled event, Emitter<ComposeState> emit) async { 351 if (state.isEditing) return; 352 emit(state.copyWith(scheduledAt: event.scheduledAt)); 353 } 354 355 Future<void> _onScheduleCleared(ScheduleCleared event, Emitter<ComposeState> emit) async { 356 if (state.isEditing) return; 357 emit(state.copyWith(scheduledAt: null)); 358 } 359 360 Future<void> _onReplyContextSet(ReplyContextSet event, Emitter<ComposeState> emit) async { 361 emit( 362 state.copyWith( 363 replyParentUri: event.parentUri, 364 replyParentCid: event.parentCid, 365 replyRootUri: event.rootUri, 366 replyRootCid: event.rootCid, 367 ), 368 ); 369 } 370 371 Future<void> _onReplyContextCleared(ReplyContextCleared event, Emitter<ComposeState> emit) async { 372 emit(state.copyWith(replyParentUri: null, replyParentCid: null, replyRootUri: null, replyRootCid: null)); 373 } 374 375 Future<void> _onQuoteContextSet(QuoteContextSet event, Emitter<ComposeState> emit) async { 376 emit(state.copyWith(quoteUri: event.quoteUri, quoteCid: event.quoteCid)); 377 } 378 379 Future<void> _onQuoteContextCleared(QuoteContextCleared event, Emitter<ComposeState> emit) async { 380 emit(state.copyWith(quoteUri: null, quoteCid: null)); 381 } 382 383 Future<void> _onEditContextSet(EditContextSet event, Emitter<ComposeState> emit) async { 384 final text = event.initialText ?? state.text; 385 final graphemeCount = text.characters.length; 386 final isOverLimit = graphemeCount > kMaxGraphemes; 387 final isEmpty = text.trim().isEmpty; 388 389 emit( 390 state.copyWith( 391 text: text, 392 graphemeCount: graphemeCount, 393 isOverLimit: isOverLimit, 394 isEmpty: isEmpty, 395 canSubmit: !isOverLimit && !isEmpty, 396 editPostUri: event.postUri, 397 editPostCid: event.postCid, 398 editRecord: Map<String, dynamic>.from(event.record), 399 scheduledAt: null, 400 isDraftDirty: false, 401 ), 402 ); 403 } 404 405 Future<void> _onPostSubmitted(PostSubmitted event, Emitter<ComposeState> emit) async { 406 if (!state.canSubmit || state.isOverLimit) return; 407 408 emit(state.copyWith(status: ComposeStatus.submitting, canSubmit: false)); 409 410 try { 411 final facets = await _collectFacets(); 412 413 if (state.isEditing) { 414 final editPostUri = state.editPostUri; 415 final editPostCid = state.editPostCid; 416 final editRecord = state.editRecord; 417 418 if (editPostUri == null || editPostCid == null || editRecord == null) { 419 _emitError(emit, 'Edit context is missing. Please reopen the editor and try again.'); 420 return; 421 } 422 423 final result = await _composeRepository.editPost( 424 postUri: editPostUri, 425 currentCid: editPostCid, 426 originalRecord: editRecord, 427 text: state.text, 428 facets: facets, 429 repo: _accountDid, 430 ); 431 432 if (result.isSuccess) { 433 emit(state.copyWith(status: ComposeStatus.success, canSubmit: false, isDraftDirty: false)); 434 } else { 435 _emitError(emit, result.errorMessage ?? 'Failed to save changes. Please try again.'); 436 } 437 return; 438 } 439 440 if (state.scheduledAt != null && state.scheduledAt!.isAfter(DateTime.now())) { 441 final embedJson = _buildEmbedJson(); 442 final draft = DraftsCompanion( 443 accountDid: Value(_accountDid), 444 content: Value(state.text), 445 replyUri: state.replyParentUri != null ? Value(state.replyParentUri!) : const Value.absent(), 446 replyCid: state.replyParentCid != null ? Value(state.replyParentCid!) : const Value.absent(), 447 rootUri: state.replyRootUri != null ? Value(state.replyRootUri!) : const Value.absent(), 448 rootCid: state.replyRootCid != null ? Value(state.replyRootCid!) : const Value.absent(), 449 embedJson: embedJson != null ? Value(jsonEncode(embedJson)) : const Value.absent(), 450 mediaPaths: state.mediaAttachments.isNotEmpty 451 ? Value(jsonEncode(state.mediaAttachments.map((m) => m.localPath).toList())) 452 : const Value.absent(), 453 scheduledAt: Value(state.scheduledAt!), 454 updatedAt: Value(DateTime.now()), 455 ); 456 final draftId = await _database.saveDraft(draft); 457 await PostScheduler.schedulePost(draftId: draftId, scheduledAt: state.scheduledAt!); 458 emit(state.copyWith(status: ComposeStatus.success, canSubmit: false)); 459 return; 460 } 461 462 Map<String, dynamic>? mediaEmbed; 463 464 if (state.mediaAttachments.isNotEmpty) { 465 final uploaded = <_UploadedImage>[]; 466 for (final attachment in state.mediaAttachments) { 467 final file = File(attachment.localPath); 468 if (!file.existsSync()) { 469 _emitError(emit, 'Image file not found. Please re-attach and try again.'); 470 return; 471 } 472 final bytes = await file.readAsBytes(); 473 474 if (bytes.length > kMaxImageBytes) { 475 final mb = (bytes.length / 1024 / 1024).toStringAsFixed(1); 476 _emitError(emit, 'Image "${attachment.localPath.split('/').last}" is $mb MB — max 1 MB.'); 477 return; 478 } 479 480 final mime = _detectImageMime(bytes); 481 if (mime == null) { 482 _emitError(emit, 'Unsupported image format. Use JPEG, PNG, or WebP.'); 483 return; 484 } 485 486 final blob = await _composeRepository.uploadBlob(bytes, mimeType: mime); 487 if (blob == null) { 488 _emitError(emit, 'Failed to upload image. Please try again.'); 489 return; 490 } 491 uploaded.add( 492 _UploadedImage( 493 blobRef: blob, 494 altText: attachment.altText, 495 width: attachment.width, 496 height: attachment.height, 497 ), 498 ); 499 } 500 501 mediaEmbed = { 502 '\$type': 'app.bsky.embed.images', 503 'images': uploaded.map((img) { 504 final entry = <String, dynamic>{'image': img.blobRef.toJson(), 'alt': img.altText}; 505 if (img.width != null && img.height != null) { 506 entry['aspectRatio'] = {'width': img.width, 'height': img.height}; 507 } 508 return entry; 509 }).toList(), 510 }; 511 } else if (state.videoAttachment?.isReady == true) { 512 final blob = state.videoAttachment!.blob!; 513 mediaEmbed = {r'$type': 'app.bsky.embed.video', 'video': blob.toJson(), 'alt': state.videoAttachment!.altText}; 514 } else { 515 final firstLink = LinkPreviewService.firstLink(state.text); 516 if (firstLink != null && firstLink != event.suppressedLinkUri) { 517 mediaEmbed = await _composeRepository.buildExternalEmbedFromLink(firstLink); 518 } 519 } 520 521 Map<String, dynamic>? embed; 522 if (state.quoteUri != null && state.quoteCid != null) { 523 if (mediaEmbed != null) { 524 embed = { 525 r'$type': 'app.bsky.embed.recordWithMedia', 526 'record': { 527 r'$type': 'app.bsky.embed.record', 528 'record': {'uri': state.quoteUri, 'cid': state.quoteCid}, 529 }, 530 'media': mediaEmbed, 531 }; 532 } else { 533 embed = { 534 r'$type': 'app.bsky.embed.record', 535 'record': {'uri': state.quoteUri, 'cid': state.quoteCid}, 536 }; 537 } 538 } else { 539 embed = mediaEmbed; 540 } 541 542 Map<String, dynamic>? reply; 543 if (state.replyParentUri != null && state.replyParentCid != null) { 544 final fallbackRootUri = state.replyRootUri ?? state.replyParentUri!; 545 final fallbackRootCid = state.replyRootCid ?? state.replyParentCid!; 546 final resolvedReplyRefs = await _composeRepository.resolveReplyReferences( 547 parentUri: state.replyParentUri!, 548 parentCid: state.replyParentCid!, 549 fallbackRootUri: fallbackRootUri, 550 fallbackRootCid: fallbackRootCid, 551 ); 552 553 final parentCid = resolvedReplyRefs?.parentCid ?? state.replyParentCid!; 554 final rootUri = resolvedReplyRefs?.rootUri ?? fallbackRootUri; 555 final rootCid = resolvedReplyRefs?.rootCid ?? fallbackRootCid; 556 557 reply = { 558 'parent': {'uri': state.replyParentUri, 'cid': parentCid}, 559 'root': {'uri': rootUri, 'cid': rootCid}, 560 }; 561 } 562 563 final success = await _composeRepository.createPost( 564 text: state.text, 565 facets: facets, 566 embed: embed, 567 reply: reply, 568 repo: _accountDid, 569 ); 570 571 if (success) { 572 if (state.draftId != null) await _database.deleteDraft(state.draftId!); 573 emit(state.copyWith(status: ComposeStatus.success, canSubmit: false)); 574 } else { 575 _emitError(emit, 'Failed to create post. Please try again.'); 576 } 577 } catch (e, stackTrace) { 578 log.e('Failed to submit post', error: e, stackTrace: stackTrace); 579 580 if (state.isEditing) { 581 _emitError(emit, 'Failed to save changes: $e'); 582 return; 583 } 584 585 await _saveFailedSubmissionAsDraft(emit, e); 586 } 587 } 588 589 Future<List<Map<String, dynamic>>> _collectFacets() async { 590 final blueskyText = BlueskyText(state.text); 591 final facets = <Map<String, dynamic>>[]; 592 593 for (final entity in blueskyText.entities) { 594 try { 595 final facet = await entity.toFacet().timeout( 596 const Duration(seconds: 5), 597 onTimeout: () { 598 log.w('Timeout resolving @${entity.value}; facet dropped.'); 599 return {}; 600 }, 601 ); 602 if (facet.isNotEmpty) facets.add(facet); 603 } catch (e) { 604 log.w('Could not resolve facet for "${entity.value}": $e'); 605 } 606 } 607 608 return facets; 609 } 610 611 Future<void> _saveFailedSubmissionAsDraft(Emitter<ComposeState> emit, Object error) async { 612 try { 613 final embedJson = _buildEmbedJson(); 614 final draft = DraftsCompanion( 615 accountDid: Value(_accountDid), 616 content: Value(state.text), 617 replyUri: state.replyParentUri != null ? Value(state.replyParentUri!) : const Value.absent(), 618 replyCid: state.replyParentCid != null ? Value(state.replyParentCid!) : const Value.absent(), 619 rootUri: state.replyRootUri != null ? Value(state.replyRootUri!) : const Value.absent(), 620 rootCid: state.replyRootCid != null ? Value(state.replyRootCid!) : const Value.absent(), 621 embedJson: embedJson != null ? Value(jsonEncode(embedJson)) : const Value.absent(), 622 mediaPaths: state.mediaAttachments.isNotEmpty 623 ? Value(jsonEncode(state.mediaAttachments.map((m) => m.localPath).toList())) 624 : const Value.absent(), 625 scheduledAt: state.scheduledAt != null ? Value(state.scheduledAt!) : const Value.absent(), 626 updatedAt: Value(DateTime.now()), 627 ); 628 await _database.saveDraft(draft); 629 _emitError(emit, 'Network error — post saved as draft.'); 630 } catch (_) { 631 _emitError(emit, 'Failed to submit post: $error'); 632 } 633 } 634 635 /// Emits error state (preserving content), then transitions back to ready 636 /// so the user can retry without losing their work. 637 void _emitError(Emitter<ComposeState> emit, String message) { 638 emit(state.copyWith(status: ComposeStatus.error, errorMessage: message, canSubmit: false)); 639 emit( 640 state.copyWith( 641 status: ComposeStatus.ready, 642 errorMessage: message, 643 canSubmit: !state.isOverLimit && !state.isEmpty, 644 ), 645 ); 646 } 647 648 Map<String, dynamic>? _buildEmbedJson() { 649 if (state.mediaAttachments.isNotEmpty) { 650 return { 651 'type': 'images', 652 'paths': state.mediaAttachments.map((m) => m.localPath).toList(), 653 'altTexts': state.mediaAttachments.map((m) => m.altText).toList(), 654 }; 655 } 656 if (state.videoAttachment != null) { 657 return {'type': 'video', 'path': state.videoAttachment!.localPath, 'alt': state.videoAttachment!.altText}; 658 } 659 return null; 660 } 661 662 /// Returns MIME type from magic bytes, or null if not an accepted image type. 663 static String? _detectImageMime(List<int> bytes) { 664 if (bytes.length < 12) return null; 665 if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) return 'image/jpeg'; 666 if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) return 'image/png'; 667 if (bytes[0] == 0x52 && 668 bytes[1] == 0x49 && 669 bytes[2] == 0x46 && 670 bytes[3] == 0x46 && 671 bytes[8] == 0x57 && 672 bytes[9] == 0x45 && 673 bytes[10] == 0x42 && 674 bytes[11] == 0x50) { 675 return 'image/webp'; 676 } 677 return null; 678 } 679} 680 681class _UploadedImage { 682 const _UploadedImage({required this.blobRef, required this.altText, this.width, this.height}); 683 684 final BlobRef blobRef; 685 final String altText; 686 final int? width; 687 final int? height; 688} 689 690class EditPostResult { 691 const EditPostResult._({required this.isSuccess, this.errorMessage, this.cid}); 692 693 const EditPostResult.success({required String cid}) : this._(isSuccess: true, cid: cid); 694 695 const EditPostResult.failure(String message) : this._(isSuccess: false, errorMessage: message); 696 697 final bool isSuccess; 698 final String? errorMessage; 699 final String? cid; 700} 701 702class ComposeRepository { 703 ComposeRepository({ 704 required Bluesky bluesky, 705 LinkPreviewService? linkPreviewService, 706 ActorRepositoryServiceResolver? actorRepositoryServiceResolver, 707 }) : _actorRepoResolver = actorRepositoryServiceResolver ?? ActorRepositoryServiceResolver(), 708 _bluesky = bluesky, 709 _linkPreviewService = linkPreviewService ?? LinkPreviewService(); 710 711 final Bluesky _bluesky; 712 final LinkPreviewService _linkPreviewService; 713 final ActorRepositoryServiceResolver _actorRepoResolver; 714 715 Future<BlobRef?> uploadBlob(List<int> bytes, {String mimeType = 'image/jpeg'}) async { 716 try { 717 final response = await _bluesky.atproto.repo.uploadBlob( 718 bytes: Uint8List.fromList(bytes), 719 $headers: {'Content-Type': mimeType}, 720 ); 721 return response.data.blob.ref; 722 } catch (e, stackTrace) { 723 log.e('Failed to upload blob', error: e, stackTrace: stackTrace); 724 return null; 725 } 726 } 727 728 /// Uploads video bytes and returns the job ID, or null on failure. 729 Future<String?> uploadVideo(Uint8List bytes) async { 730 try { 731 final response = await _bluesky.video.uploadVideo(bytes: bytes); 732 return response.data.jobId; 733 } catch (e, stackTrace) { 734 log.e('Failed to upload video', error: e, stackTrace: stackTrace); 735 return null; 736 } 737 } 738 739 Future<dynamic> getJobStatus(String jobId) async { 740 try { 741 final response = await _bluesky.video.getJobStatus(jobId: jobId); 742 return response.data.jobStatus; 743 } catch (e, stackTrace) { 744 log.e('Failed to get job status', error: e, stackTrace: stackTrace); 745 return null; 746 } 747 } 748 749 Future<({bool canUpload, String? message})?> getUploadLimits() async { 750 try { 751 final response = await _bluesky.video.getUploadLimits(); 752 final d = response.data; 753 return (canUpload: d.canUpload, message: d.message ?? d.error); 754 } catch (e, stackTrace) { 755 log.e('Failed to get upload limits', error: e, stackTrace: stackTrace); 756 return null; 757 } 758 } 759 760 Future<bool> createPost({ 761 required String text, 762 required List<Map<String, dynamic>> facets, 763 Map<String, dynamic>? embed, 764 Map<String, dynamic>? reply, 765 required String repo, 766 }) async { 767 try { 768 final record = <String, dynamic>{ 769 '\$type': 'app.bsky.feed.post', 770 'text': text, 771 'createdAt': DateTime.now().toUtc().toIso8601String(), 772 'langs': ['en'], 773 }; 774 if (facets.isNotEmpty) record['facets'] = facets; 775 if (embed != null) record['embed'] = embed; 776 if (reply != null) record['reply'] = reply; 777 778 await _bluesky.atproto.repo.createRecord(repo: repo, collection: 'app.bsky.feed.post', record: record); 779 return true; 780 } catch (e, stackTrace) { 781 log.e('Failed to create post', error: e, stackTrace: stackTrace); 782 return false; 783 } 784 } 785 786 Future<LinkPreviewData?> fetchLinkPreview(String rawUrl) async { 787 try { 788 return await _linkPreviewService.fetch(rawUrl); 789 } catch (error, stackTrace) { 790 log.w('Failed to fetch link preview metadata', error: error, stackTrace: stackTrace); 791 return null; 792 } 793 } 794 795 Future<Map<String, dynamic>?> buildExternalEmbedFromLink(String rawUrl) async { 796 final preview = await fetchLinkPreview(rawUrl); 797 if (preview == null) { 798 return null; 799 } 800 801 final external = <String, dynamic>{'uri': preview.uri, 'title': preview.title, 'description': preview.description}; 802 803 final thumbUrl = preview.thumbnailUrl; 804 if (thumbUrl != null && thumbUrl.isNotEmpty) { 805 final thumb = await _uploadExternalThumb(thumbUrl); 806 if (thumb != null) { 807 external['thumb'] = thumb.toJson(); 808 } 809 } 810 811 return {r'$type': 'app.bsky.embed.external', 'external': external}; 812 } 813 814 Future<({String parentCid, String rootUri, String rootCid})?> resolveReplyReferences({ 815 required String parentUri, 816 required String parentCid, 817 required String fallbackRootUri, 818 required String fallbackRootCid, 819 }) async { 820 try { 821 final parentAtUri = AtUri.parse(parentUri); 822 final parent = await _getRecordFromRepo( 823 repo: parentAtUri.hostname, 824 collection: parentAtUri.collection.toString(), 825 rkey: parentAtUri.rkey, 826 ); 827 828 final latestParentCidRaw = parent.data.cid; 829 final latestParentCid = latestParentCidRaw is String && latestParentCidRaw.isNotEmpty 830 ? latestParentCidRaw 831 : parentCid; 832 final parentValue = parent.data.value; 833 if (parentValue is! Map<String, dynamic>) { 834 return (parentCid: latestParentCid, rootUri: parentUri, rootCid: latestParentCid); 835 } 836 837 final parentReply = parentValue['reply']; 838 if (parentReply is! Map) { 839 return (parentCid: latestParentCid, rootUri: parentUri, rootCid: latestParentCid); 840 } 841 842 final rootRef = parentReply['root']; 843 if (rootRef is! Map) { 844 return (parentCid: latestParentCid, rootUri: fallbackRootUri, rootCid: fallbackRootCid); 845 } 846 847 final rootUri = rootRef['uri']; 848 final rootCid = rootRef['cid']; 849 if (rootUri is String && rootCid is String && rootUri.isNotEmpty && rootCid.isNotEmpty) { 850 return (parentCid: latestParentCid, rootUri: rootUri, rootCid: rootCid); 851 } 852 853 return (parentCid: latestParentCid, rootUri: fallbackRootUri, rootCid: fallbackRootCid); 854 } catch (error, stackTrace) { 855 log.w('Failed to resolve reply references; using fallback refs', error: error, stackTrace: stackTrace); 856 return null; 857 } 858 } 859 860 Future<BlobRef?> _uploadExternalThumb(String thumbUrl) async { 861 try { 862 final thumb = await _linkPreviewService.fetchThumbnail(thumbUrl); 863 if (thumb == null) { 864 return null; 865 } 866 return await uploadBlob(thumb.bytes, mimeType: thumb.mimeType); 867 } catch (error, stackTrace) { 868 log.w('Failed to upload external embed thumbnail blob', error: error, stackTrace: stackTrace); 869 return null; 870 } 871 } 872 873 Future<EditPostResult> editPost({ 874 required String postUri, 875 required String currentCid, 876 required Map<String, dynamic> originalRecord, 877 required String text, 878 required List<Map<String, dynamic>> facets, 879 required String repo, 880 }) async { 881 try { 882 final atUri = AtUri.parse(postUri); 883 final targetRepo = atUri.hostname.isNotEmpty ? atUri.hostname : repo; 884 final collection = atUri.collection.toString(); 885 final rkey = atUri.rkey; 886 final latest = await _getRecordFromRepo(repo: targetRepo, collection: collection, rkey: rkey); 887 888 final latestValue = latest.data.value; 889 final latestRecord = latestValue is Map ? Map<String, dynamic>.from(latestValue) : <String, dynamic>{}; 890 final baseRecord = latestRecord.isNotEmpty ? latestRecord : originalRecord; 891 final latestCid = latest.data.cid; 892 final swapCid = latestCid is String && latestCid.isNotEmpty ? latestCid : currentCid; 893 final updatedRecord = Map<String, dynamic>.from(baseRecord); 894 updatedRecord['text'] = text; 895 if (facets.isNotEmpty) { 896 updatedRecord['facets'] = facets; 897 } else { 898 updatedRecord.remove('facets'); 899 } 900 901 final existingCreatedAt = baseRecord['createdAt']; 902 if (existingCreatedAt is String && existingCreatedAt.trim().isNotEmpty) { 903 updatedRecord['createdAt'] = existingCreatedAt; 904 } else { 905 updatedRecord['createdAt'] = DateTime.now().toUtc().toIso8601String(); 906 } 907 updatedRecord[r'$type'] = 'app.bsky.feed.post'; 908 909 await _bluesky.atproto.repo.deleteRecord( 910 repo: targetRepo, 911 collection: collection, 912 rkey: rkey, 913 swapRecord: swapCid, 914 ); 915 916 late final String newCid; 917 try { 918 final created = await _bluesky.atproto.repo.createRecord( 919 repo: targetRepo, 920 collection: collection, 921 rkey: rkey, 922 record: updatedRecord, 923 ); 924 newCid = created.data.cid; 925 } on XRPCException catch (e, stackTrace) { 926 log.e('Failed to recreate post during edit; checking current state', error: e, stackTrace: stackTrace); 927 928 final snapshot = await _tryGetRecordSnapshot(repo: targetRepo, collection: collection, rkey: rkey); 929 if (snapshot != null) { 930 final persistedText = snapshot.value['text']; 931 if (persistedText is String && persistedText == text) { 932 return EditPostResult.success(cid: snapshot.cid ?? currentCid); 933 } 934 return const EditPostResult.failure('This post was changed elsewhere. Reopen it and try editing again.'); 935 } 936 937 final restored = await _restoreOriginalRecord( 938 repo: targetRepo, 939 collection: collection, 940 rkey: rkey, 941 originalRecord: baseRecord, 942 ); 943 if (restored) { 944 return const EditPostResult.failure('Could not save changes. Your original post was restored.'); 945 } 946 947 return const EditPostResult.failure( 948 'Could not save changes and we could not confirm recovery. Reopen the thread and verify the post.', 949 ); 950 } 951 952 final verified = await _getRecordFromRepo(repo: targetRepo, collection: collection, rkey: rkey); 953 final verifiedValue = verified.data.value; 954 final persistedText = verifiedValue is Map ? verifiedValue['text'] : null; 955 if (persistedText is! String || persistedText != text) { 956 return const EditPostResult.failure( 957 'Edit was submitted but could not be confirmed yet. Please reopen the post and verify.', 958 ); 959 } 960 961 return EditPostResult.success(cid: newCid); 962 } on XRPCException catch (e, stackTrace) { 963 final errorCode = e.response.data.error; 964 final errorMessage = e.response.data.message ?? ''; 965 log.e('Failed to edit post', error: e, stackTrace: stackTrace); 966 967 if (errorCode == 'InvalidSwap' || errorMessage.contains('Record was at')) { 968 return const EditPostResult.failure('This post was changed elsewhere. Reopen it and try editing again.'); 969 } 970 971 if (errorCode == 'RecordNotFound' || errorCode == 'NotFound') { 972 return const EditPostResult.failure('This post is no longer available. Reopen the thread and try again.'); 973 } 974 975 return EditPostResult.failure( 976 errorMessage.isNotEmpty ? errorMessage : 'Failed to save changes. Please try again.', 977 ); 978 } catch (e, stackTrace) { 979 log.e('Failed to edit post', error: e, stackTrace: stackTrace); 980 return const EditPostResult.failure('Failed to save changes. Please try again.'); 981 } 982 } 983 984 Future<({Map<String, dynamic> value, String? cid})?> _tryGetRecordSnapshot({ 985 required String repo, 986 required String collection, 987 required String rkey, 988 }) async { 989 try { 990 final response = await _getRecordFromRepo(repo: repo, collection: collection, rkey: rkey); 991 final value = response.data.value; 992 if (value is! Map) { 993 return null; 994 } 995 final cid = response.data.cid; 996 return (value: Map<String, dynamic>.from(value), cid: cid is String ? cid : null); 997 } on XRPCException catch (e, stackTrace) { 998 final errorCode = e.response.data.error; 999 if (errorCode == 'RecordNotFound' || errorCode == 'NotFound') { 1000 return null; 1001 } 1002 log.w('Failed to read post snapshot during edit recovery', error: e, stackTrace: stackTrace); 1003 return null; 1004 } catch (e, stackTrace) { 1005 log.w('Failed to read post snapshot during edit recovery', error: e, stackTrace: stackTrace); 1006 return null; 1007 } 1008 } 1009 1010 Future<bool> _restoreOriginalRecord({ 1011 required String repo, 1012 required String collection, 1013 required String rkey, 1014 required Map<String, dynamic> originalRecord, 1015 }) async { 1016 final restoredRecord = Map<String, dynamic>.from(originalRecord); 1017 restoredRecord[r'$type'] = 'app.bsky.feed.post'; 1018 final existingCreatedAt = restoredRecord['createdAt']; 1019 if (existingCreatedAt is! String || existingCreatedAt.trim().isEmpty) { 1020 restoredRecord['createdAt'] = DateTime.now().toUtc().toIso8601String(); 1021 } 1022 1023 try { 1024 await _bluesky.atproto.repo.createRecord(repo: repo, collection: collection, rkey: rkey, record: restoredRecord); 1025 return true; 1026 } catch (e, stackTrace) { 1027 log.e('Failed to restore original record after edit failure', error: e, stackTrace: stackTrace); 1028 return false; 1029 } 1030 } 1031 1032 Future<dynamic> _getRecordFromRepo({required String repo, required String collection, required String rkey}) async { 1033 final serviceHost = await _resolveRepoServiceHost(repo); 1034 return _bluesky.atproto.repo.getRecord(repo: repo, collection: collection, rkey: rkey, $service: serviceHost); 1035 } 1036 1037 Future<String?> _resolveRepoServiceHost(String repo) async { 1038 if (_isCurrentSessionRepo(repo)) { 1039 return null; 1040 } 1041 1042 try { 1043 final resolved = await _actorRepoResolver.resolve(repo); 1044 return resolved.pdsHost; 1045 } catch (error, stackTrace) { 1046 log.w( 1047 'ComposeRepository: Failed to resolve non-self repo host for $repo; aborting foreign repo read', 1048 error: error, 1049 stackTrace: stackTrace, 1050 ); 1051 rethrow; 1052 } 1053 } 1054 1055 bool _isCurrentSessionRepo(String repo) { 1056 final normalizedRepo = repo.trim().toLowerCase(); 1057 if (normalizedRepo.isEmpty) { 1058 return false; 1059 } 1060 1061 final sessionDid = _bluesky.session?.did.trim().toLowerCase(); 1062 if (sessionDid != null && sessionDid.isNotEmpty && normalizedRepo == sessionDid) { 1063 return true; 1064 } 1065 1066 final oauthDid = _bluesky.oAuthSession?.sub.trim().toLowerCase(); 1067 return oauthDid != null && oauthDid.isNotEmpty && normalizedRepo == oauthDid; 1068 } 1069} 1070 1071/// Image dimension helper (used by compose screen when picking images). 1072Future<({int width, int height})?> readImageDimensions(List<int> bytes) async { 1073 try { 1074 final completer = Completer<ui.Image>(); 1075 ui.decodeImageFromList(Uint8List.fromList(bytes), completer.complete); 1076 final image = await completer.future; 1077 final result = (width: image.width, height: image.height); 1078 image.dispose(); 1079 return result; 1080 } catch (_) { 1081 return null; 1082 } 1083}