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