feat: анимации с реанимации
This commit is contained in:
@@ -10,6 +10,7 @@ import '../../core/cache/info_cache.dart';
|
||||
import '../../core/cache/message_session_cache.dart';
|
||||
import 'shared_content.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/chat_members_store.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../core/utils/text_format.dart';
|
||||
@@ -559,6 +560,41 @@ class ChatsModule {
|
||||
final ValueNotifier<int> chatsChanged = ValueNotifier(0);
|
||||
void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
||||
|
||||
static const Set<String> _membershipEvents = {
|
||||
'add',
|
||||
'joinByLink',
|
||||
'leave',
|
||||
'remove',
|
||||
};
|
||||
|
||||
void _applyMembershipControl(
|
||||
int accountId,
|
||||
int chatId,
|
||||
CachedMessage message,
|
||||
) {
|
||||
final control = message.controlAttachment;
|
||||
final event = control?.event;
|
||||
if (control == null || event == null) return;
|
||||
if (!_membershipEvents.contains(event)) return;
|
||||
|
||||
if (message.senderId != accountId) {
|
||||
final affected = control.userIds?.length ?? 1;
|
||||
ChatMembersStore.instance.adjust(chatId, switch (event) {
|
||||
'add' => affected,
|
||||
'joinByLink' => 1,
|
||||
'leave' => -1,
|
||||
'remove' => -affected,
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
_refreshChatInfo(chatId);
|
||||
}
|
||||
|
||||
void _refreshChatInfo(int chatId) {
|
||||
ChatInfoFetch.invalidate(chatId);
|
||||
unawaited(ChatInfoFetch.get(chatId));
|
||||
}
|
||||
|
||||
Future<bool> _updateChat(
|
||||
int accountId,
|
||||
int chatId,
|
||||
@@ -819,6 +855,7 @@ class ChatsModule {
|
||||
final cached = CachedMessage.fromPushPayload(accountId, chatId, msg);
|
||||
await AppDatabase.saveMessages([cached.toDbRow()]);
|
||||
emittedMessage = cached;
|
||||
_applyMembershipControl(accountId, chatId, cached);
|
||||
_messageEventsController.add(MessageAddedEvent(chatId, cached));
|
||||
}
|
||||
}
|
||||
@@ -1160,6 +1197,7 @@ class ChatsModule {
|
||||
}) async {
|
||||
final cachedAt = DateTime.now().millisecondsSinceEpoch;
|
||||
final id = chat['id'];
|
||||
ChatMembersStore.instance.applyChatPayload(chat);
|
||||
Map<int, CachedChat> existing = const {};
|
||||
Map<String, dynamic>? existingRow;
|
||||
if (preloadedExisting != null) {
|
||||
@@ -1260,6 +1298,7 @@ class ChatsModule {
|
||||
final rows = <Map<String, dynamic>>[];
|
||||
for (final c in chats.whereType<Map>()) {
|
||||
final map = c.cast<dynamic, dynamic>();
|
||||
ChatMembersStore.instance.applyChatPayload(map);
|
||||
final parsed = parseChatRow(
|
||||
map,
|
||||
accountId,
|
||||
@@ -1360,7 +1399,9 @@ class ChatsModule {
|
||||
final payload = packet.payload as Map?;
|
||||
final chats = payload?['chats'] as List?;
|
||||
if (chats == null || chats.isEmpty) return null;
|
||||
return Map<String, dynamic>.from(chats.first as Map);
|
||||
final info = Map<String, dynamic>.from(chats.first as Map);
|
||||
ChatMembersStore.instance.applyChatPayload(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getReadMarks(Api api, int accountId, int chatId) async {
|
||||
@@ -1461,6 +1502,8 @@ class ChatsModule {
|
||||
throw const PacketError('Не удалось подписаться');
|
||||
}
|
||||
final count = chatMap['participantsCount'];
|
||||
if (count is! int) ChatMembersStore.instance.adjust(cached.id, 1);
|
||||
_refreshChatInfo(cached.id);
|
||||
return (chat: cached, subscribersCount: count is int ? count : null);
|
||||
}
|
||||
|
||||
@@ -1850,6 +1893,10 @@ class ChatsModule {
|
||||
await cacheServerChat(chat.cast<dynamic, dynamic>(), accountId);
|
||||
}
|
||||
}
|
||||
if (chat is! Map || chat['participantsCount'] is! int) {
|
||||
ChatMembersStore.instance.adjust(chatId, userIds.length);
|
||||
}
|
||||
_refreshChatInfo(chatId);
|
||||
return true;
|
||||
} on PacketError catch (e) {
|
||||
logger.w('addMembers $chatId: ${e.message}');
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/cache/message_session_cache.dart';
|
||||
import '../../core/media/gallery_source.dart';
|
||||
import '../../core/media/image_optimizer.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../main.dart' show fileUploader, messagesModule;
|
||||
import 'messages.dart';
|
||||
|
||||
sealed class MediaSendEvent {
|
||||
final int chatId;
|
||||
final String tempId;
|
||||
final bool scheduled;
|
||||
|
||||
const MediaSendEvent({
|
||||
required this.chatId,
|
||||
required this.tempId,
|
||||
required this.scheduled,
|
||||
});
|
||||
}
|
||||
|
||||
class MediaSendDone extends MediaSendEvent {
|
||||
final CachedMessage? message;
|
||||
final int? scheduledTime;
|
||||
|
||||
const MediaSendDone({
|
||||
required super.chatId,
|
||||
required super.tempId,
|
||||
required super.scheduled,
|
||||
this.message,
|
||||
this.scheduledTime,
|
||||
});
|
||||
}
|
||||
|
||||
class MediaSendFailed extends MediaSendEvent {
|
||||
const MediaSendFailed({
|
||||
required super.chatId,
|
||||
required super.tempId,
|
||||
required super.scheduled,
|
||||
});
|
||||
}
|
||||
|
||||
class MediaSendService {
|
||||
MediaSendService._();
|
||||
|
||||
static final MediaSendService instance = MediaSendService._();
|
||||
|
||||
final StreamController<MediaSendEvent> _events =
|
||||
StreamController<MediaSendEvent>.broadcast();
|
||||
|
||||
static const int _historyLimit = 60;
|
||||
static const int _photoConcurrency = 3;
|
||||
static const int _photoAttempts = 3;
|
||||
|
||||
final Map<String, ValueNotifier<List<double>>> _progress = {};
|
||||
final Map<int, List<CachedMessage>> _pending = {};
|
||||
final Map<String, CachedMessage> _completed = {};
|
||||
final Set<String> _failed = {};
|
||||
|
||||
Stream<MediaSendEvent> get events => _events.stream;
|
||||
|
||||
ValueListenable<List<double>>? progressFor(String tempId) =>
|
||||
_progress[tempId];
|
||||
|
||||
List<CachedMessage> pendingFor(int chatId) =>
|
||||
List<CachedMessage>.unmodifiable(_pending[chatId] ?? const []);
|
||||
|
||||
CachedMessage? completedFor(String tempId) => _completed[tempId];
|
||||
|
||||
bool didFail(String tempId) => _failed.contains(tempId);
|
||||
|
||||
Future<void> sendPhotos({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required List<({File file, GalleryItem? item})> jobs,
|
||||
required String caption,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
slots: jobs.length,
|
||||
upload: (progress) async {
|
||||
final tokens = await _uploadPhotos(jobs, progress);
|
||||
if (tokens.any((t) => t == null)) return null;
|
||||
return messagesModule.sendPhotoMessage(
|
||||
chatId,
|
||||
tokens.cast<String>(),
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendVideo({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File file,
|
||||
required String caption,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
slots: 1,
|
||||
upload: (progress) async {
|
||||
final info = await messagesModule.requestVideoUploadUrl();
|
||||
if (info == null || info.url.isEmpty) return null;
|
||||
final ok = await fileUploader.uploadVideoFile(
|
||||
Uri.parse(info.url),
|
||||
file,
|
||||
onProgress: (sent, total) {
|
||||
if (total > 0) {
|
||||
progress.value = [(sent / total).clamp(0.0, 1.0)];
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!ok) return null;
|
||||
progress.value = const [1];
|
||||
return messagesModule.sendVideoMessage(
|
||||
chatId,
|
||||
info.token,
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _run({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required int slots,
|
||||
required Future<Map<String, dynamic>?> Function(
|
||||
ValueNotifier<List<double>> progress,
|
||||
)
|
||||
upload,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) async {
|
||||
final scheduled = scheduledTime != null;
|
||||
final progress = ValueNotifier<List<double>>(
|
||||
List<double>.filled(slots < 1 ? 1 : slots, 0),
|
||||
);
|
||||
_progress[tempId] = progress;
|
||||
if (placeholder != null) {
|
||||
(_pending[chatId] ??= <CachedMessage>[]).add(placeholder);
|
||||
}
|
||||
|
||||
try {
|
||||
final serverMsg = await upload(progress);
|
||||
if (serverMsg == null) throw Exception('send_failed');
|
||||
|
||||
if (scheduled) {
|
||||
_finish(chatId, tempId);
|
||||
_events.add(
|
||||
MediaSendDone(
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
scheduled: true,
|
||||
scheduledTime: scheduledTime,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final real = CachedMessage.fromPushPayload(accountId, chatId, serverMsg);
|
||||
try {
|
||||
await AppDatabase.saveMessages([real.toDbRow()]);
|
||||
if (real.id != tempId) {
|
||||
await AppDatabase.deleteMessage(accountId, chatId, tempId);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('MediaSendService: не удалось сохранить сообщение: $e');
|
||||
}
|
||||
_replaceInSessionCache(accountId, chatId, tempId, real);
|
||||
_remember(tempId, real);
|
||||
_finish(chatId, tempId);
|
||||
_events.add(
|
||||
MediaSendDone(
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
scheduled: false,
|
||||
message: real,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w('MediaSendService: $e');
|
||||
if (!scheduled) {
|
||||
_replaceInSessionCache(accountId, chatId, tempId, null);
|
||||
_remember(tempId, null);
|
||||
}
|
||||
_finish(chatId, tempId);
|
||||
_events.add(
|
||||
MediaSendFailed(chatId: chatId, tempId: tempId, scheduled: scheduled),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _remember(String tempId, CachedMessage? real) {
|
||||
if (_completed.length + _failed.length > _historyLimit) {
|
||||
_completed.clear();
|
||||
_failed.clear();
|
||||
}
|
||||
if (real == null) {
|
||||
_failed.add(tempId);
|
||||
} else {
|
||||
_completed[tempId] = real;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String?>> _uploadPhotos(
|
||||
List<({File file, GalleryItem? item})> jobs,
|
||||
ValueNotifier<List<double>> progress,
|
||||
) async {
|
||||
final tokens = List<String?>.filled(jobs.length, null);
|
||||
var nextIndex = 0;
|
||||
var failed = false;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (!failed) {
|
||||
final i = nextIndex++;
|
||||
if (i >= jobs.length) return;
|
||||
final token = await _uploadOnePhoto(jobs[i], i, progress);
|
||||
if (token == null) {
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
tokens[i] = token;
|
||||
}
|
||||
}
|
||||
|
||||
final workerCount = jobs.length < _photoConcurrency
|
||||
? jobs.length
|
||||
: _photoConcurrency;
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
Future<String?> _uploadOnePhoto(
|
||||
({File file, GalleryItem? item}) job,
|
||||
int index,
|
||||
ValueNotifier<List<double>> progress,
|
||||
) async {
|
||||
File file;
|
||||
try {
|
||||
file = await optimizePhotoForUpload(job.file, item: job.item);
|
||||
} catch (e) {
|
||||
logger.w('optimize photo: $e');
|
||||
file = job.file;
|
||||
}
|
||||
for (var attempt = 0; attempt < _photoAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await Future.delayed(Duration(seconds: attempt));
|
||||
_setSlot(progress, index, 0);
|
||||
}
|
||||
try {
|
||||
final url = await messagesModule.requestPhotoUploadUrl();
|
||||
if (url == null || url.isEmpty) continue;
|
||||
final token = await fileUploader.uploadPhoto(
|
||||
Uri.parse(url),
|
||||
file,
|
||||
filename: _photoFilename(file),
|
||||
onProgress: (sent, total) {
|
||||
if (total <= 0) return;
|
||||
_setSlot(progress, index, (sent / total).clamp(0.0, 1.0));
|
||||
},
|
||||
);
|
||||
if (token != null) return token;
|
||||
} catch (e) {
|
||||
logger.w('uploadOnePhoto attempt ${attempt + 1}: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _setSlot(
|
||||
ValueNotifier<List<double>> progress,
|
||||
int index,
|
||||
double value,
|
||||
) {
|
||||
final next = List<double>.from(progress.value);
|
||||
if (index < next.length) {
|
||||
next[index] = value;
|
||||
progress.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
String _photoFilename(File file) {
|
||||
final segments = file.uri.pathSegments;
|
||||
final name = segments.isNotEmpty ? segments.last : '';
|
||||
return name.isNotEmpty ? name : 'photo.jpg';
|
||||
}
|
||||
|
||||
void _finish(int chatId, String tempId) {
|
||||
_progress.remove(tempId);
|
||||
final list = _pending[chatId];
|
||||
if (list == null) return;
|
||||
list.removeWhere((m) => m.id == tempId);
|
||||
if (list.isEmpty) _pending.remove(chatId);
|
||||
}
|
||||
|
||||
void _replaceInSessionCache(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String tempId,
|
||||
CachedMessage? real,
|
||||
) {
|
||||
final cached = MessageSessionCache.get(accountId, chatId);
|
||||
if (cached == null) return;
|
||||
final list = List<CachedMessage>.of(cached.messages);
|
||||
final idx = list.indexWhere((m) => m.id == tempId);
|
||||
if (idx == -1) return;
|
||||
list[idx] = real ?? list[idx].copyWith(status: 'error');
|
||||
MessageSessionCache.save(
|
||||
accountId,
|
||||
chatId,
|
||||
list,
|
||||
reachedStart: cached.reachedStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../main.dart';
|
||||
import 'cloud_storage.dart';
|
||||
import 'file_uploader.dart';
|
||||
import 'upload_notification_service.dart';
|
||||
|
||||
class UploadManager {
|
||||
UploadManager._();
|
||||
static final instance = UploadManager._();
|
||||
|
||||
StreamSubscription<UploadEvent>? _sub;
|
||||
bool get isActive => _sub != null;
|
||||
|
||||
// UI callbacks — registered by the screen while it is mounted
|
||||
void Function(double progress, int speedBps)? onProgress;
|
||||
void Function(CloudFile file)? onDone;
|
||||
void Function(String error)? onError;
|
||||
|
||||
Future<void> start({
|
||||
required int chatId,
|
||||
required int accountId,
|
||||
required File file,
|
||||
required String filename,
|
||||
required int totalSize,
|
||||
}) async {
|
||||
await cancel(); // cancel any previous upload
|
||||
|
||||
await UploadNotificationService.start(filename);
|
||||
|
||||
var lastSentBytes = 0;
|
||||
var lastSpeedMs = DateTime.now().millisecondsSinceEpoch;
|
||||
var speedBps = 0;
|
||||
var lastNotifPercent = -1;
|
||||
|
||||
_sub = fileUploader
|
||||
.upload(
|
||||
chatId: chatId,
|
||||
file: file,
|
||||
filename: filename,
|
||||
totalSize: totalSize,
|
||||
)
|
||||
.listen(
|
||||
(event) async {
|
||||
switch (event) {
|
||||
case UploadProgress(:final sent, :final total):
|
||||
final progress = total > 0 ? sent / total : 0.0;
|
||||
|
||||
// Speed: recompute every 500 ms
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = nowMs - lastSpeedMs;
|
||||
if (elapsed >= 500) {
|
||||
speedBps = ((sent - lastSentBytes) * 1000 / elapsed).round();
|
||||
lastSentBytes = sent;
|
||||
lastSpeedMs = nowMs;
|
||||
}
|
||||
|
||||
onProgress?.call(progress, speedBps);
|
||||
|
||||
// Throttle notification to once per 1% change
|
||||
final percent = total > 0 ? (sent * 100 ~/ total) : 0;
|
||||
if (percent != lastNotifPercent) {
|
||||
lastNotifPercent = percent;
|
||||
UploadNotificationService.update(
|
||||
filename: filename,
|
||||
progressPercent: percent,
|
||||
speedBps: speedBps,
|
||||
);
|
||||
}
|
||||
|
||||
case UploadDone(:final fileId):
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
final newest = await CloudStorageModule.fetchLatestFile(
|
||||
messagesModule,
|
||||
accountId,
|
||||
chatId,
|
||||
expectedFileId: fileId,
|
||||
);
|
||||
if (newest != null) {
|
||||
onDone?.call(newest);
|
||||
}
|
||||
|
||||
case UploadError(:final message):
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
onError?.call(message);
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
await UploadNotificationService.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,192 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class UploadNotificationService {
|
||||
static const _ch = MethodChannel('ru.komet.app/upload_service');
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../main.dart' show KometApp;
|
||||
|
||||
static Future<void> start(String filename) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try { await _ch.invokeMethod('start', {'filename': filename}); } catch (_) {}
|
||||
enum UploadKind { photo, video, file }
|
||||
|
||||
class _NotificationJob {
|
||||
_NotificationJob({required this.kind, required this.count, this.filename});
|
||||
|
||||
final UploadKind kind;
|
||||
final int count;
|
||||
final String? filename;
|
||||
|
||||
int sent = 0;
|
||||
int total = 0;
|
||||
double fraction = 0;
|
||||
int speedBps = 0;
|
||||
|
||||
int _windowSent = 0;
|
||||
int _windowAt = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
void report(int sentBytes, int totalBytes, double jobFraction) {
|
||||
sent = sentBytes;
|
||||
total = totalBytes;
|
||||
fraction = jobFraction.clamp(0.0, 1.0);
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = now - _windowAt;
|
||||
if (elapsed < 500) return;
|
||||
final delta = sent - _windowSent;
|
||||
speedBps = delta <= 0 ? 0 : (delta * 1000 / elapsed).round();
|
||||
_windowSent = sent;
|
||||
_windowAt = now;
|
||||
}
|
||||
|
||||
static Future<void> update({
|
||||
required String filename,
|
||||
required int progressPercent,
|
||||
required int speedBps,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
String label(AppLocalizations l10n) {
|
||||
final name = filename;
|
||||
return switch (kind) {
|
||||
UploadKind.photo => l10n.uploadNotificationPhotos(count),
|
||||
UploadKind.video => l10n.uploadNotificationVideo,
|
||||
UploadKind.file =>
|
||||
name == null || name.isEmpty ? l10n.uploadNotificationFile : name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class UploadNotificationService {
|
||||
static const MethodChannel _channel = MethodChannel(
|
||||
'ru.komet.app/upload_service',
|
||||
);
|
||||
static const int _minIntervalMs = 350;
|
||||
|
||||
static final Map<String, _NotificationJob> _jobs = {};
|
||||
static bool _running = false;
|
||||
static String? _lastTitle;
|
||||
static String? _lastBody;
|
||||
static int _lastPercent = -1;
|
||||
static int _lastPushAt = 0;
|
||||
|
||||
static bool get _enabled =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
static void begin(
|
||||
String id, {
|
||||
required UploadKind kind,
|
||||
int count = 1,
|
||||
String? filename,
|
||||
}) {
|
||||
if (!_enabled) return;
|
||||
_jobs[id] = _NotificationJob(
|
||||
kind: kind,
|
||||
count: count < 1 ? 1 : count,
|
||||
filename: filename,
|
||||
);
|
||||
_push(force: true);
|
||||
}
|
||||
|
||||
static void report(
|
||||
String id, {
|
||||
required int sent,
|
||||
required int total,
|
||||
required double fraction,
|
||||
}) {
|
||||
if (!_enabled) return;
|
||||
final job = _jobs[id];
|
||||
if (job == null) return;
|
||||
job.report(sent, total, fraction);
|
||||
_push();
|
||||
}
|
||||
|
||||
static void end(String id) {
|
||||
if (!_enabled) return;
|
||||
if (_jobs.remove(id) == null) return;
|
||||
if (_jobs.isEmpty) {
|
||||
_stop();
|
||||
return;
|
||||
}
|
||||
_push(force: true);
|
||||
}
|
||||
|
||||
static void _stop() {
|
||||
_running = false;
|
||||
_lastTitle = null;
|
||||
_lastBody = null;
|
||||
_lastPercent = -1;
|
||||
_lastPushAt = 0;
|
||||
_invoke('stop', const <String, dynamic>{});
|
||||
}
|
||||
|
||||
static void _push({bool force = false}) {
|
||||
if (_jobs.isEmpty) return;
|
||||
|
||||
var sumSent = 0;
|
||||
var sumTotal = 0;
|
||||
var sumSpeed = 0;
|
||||
var fractionSum = 0.0;
|
||||
var sizesKnown = true;
|
||||
for (final job in _jobs.values) {
|
||||
sumSent += job.sent;
|
||||
sumTotal += job.total;
|
||||
sumSpeed += job.speedBps;
|
||||
fractionSum += job.fraction;
|
||||
if (job.total <= 0) sizesKnown = false;
|
||||
}
|
||||
|
||||
final fraction = sizesKnown && sumTotal > 0
|
||||
? sumSent / sumTotal
|
||||
: fractionSum / _jobs.length;
|
||||
final percent = (fraction * 100).round().clamp(0, 100);
|
||||
|
||||
final l10n = _localizations();
|
||||
final title = _jobs.length == 1
|
||||
? _jobs.values.first.label(l10n)
|
||||
: l10n.uploadNotificationMultiple(_jobs.length);
|
||||
final body = percent <= 0 && sumSpeed <= 0
|
||||
? l10n.uploadNotificationPreparing
|
||||
: sumSpeed > 0
|
||||
? '$percent% · ${_formatSpeed(l10n, sumSpeed)}'
|
||||
: '$percent%';
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final changed =
|
||||
title != _lastTitle || body != _lastBody || percent != _lastPercent;
|
||||
if (!force && (!changed || now - _lastPushAt < _minIntervalMs)) return;
|
||||
|
||||
_lastTitle = title;
|
||||
_lastBody = body;
|
||||
_lastPercent = percent;
|
||||
_lastPushAt = now;
|
||||
|
||||
final args = <String, dynamic>{
|
||||
'title': title,
|
||||
'body': body,
|
||||
'progress': percent,
|
||||
'indeterminate': percent <= 0 && sumSpeed <= 0,
|
||||
};
|
||||
if (_running) {
|
||||
_invoke('update', args);
|
||||
return;
|
||||
}
|
||||
_running = true;
|
||||
_invoke('start', args);
|
||||
}
|
||||
|
||||
static String _formatSpeed(AppLocalizations l10n, int bps) {
|
||||
if (bps < 1024) return l10n.uploadSpeedBytes('$bps');
|
||||
if (bps < 1024 * 1024) return l10n.uploadSpeedKb('${(bps / 1024).round()}');
|
||||
return l10n.uploadSpeedMb((bps / (1024 * 1024)).toStringAsFixed(1));
|
||||
}
|
||||
|
||||
static AppLocalizations _localizations() {
|
||||
final context = KometApp.navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
final scoped = Localizations.of<AppLocalizations>(
|
||||
context,
|
||||
AppLocalizations,
|
||||
);
|
||||
if (scoped != null) return scoped;
|
||||
}
|
||||
final code = WidgetsBinding.instance.platformDispatcher.locale.languageCode;
|
||||
return lookupAppLocalizations(Locale(code == 'ru' ? 'ru' : 'en'));
|
||||
}
|
||||
|
||||
static Future<void> _invoke(String method, Map<String, dynamic> args) async {
|
||||
try {
|
||||
await _ch.invokeMethod('update', {
|
||||
'filename': filename,
|
||||
'progress': progressPercent,
|
||||
'speed': speedBps,
|
||||
});
|
||||
await _channel.invokeMethod(method, args);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> stop() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try { await _ch.invokeMethod('stop'); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/cache/message_session_cache.dart';
|
||||
import '../../core/media/gallery_source.dart';
|
||||
import '../../core/media/image_optimizer.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../main.dart' show fileUploader, messagesModule;
|
||||
import '../../models/attachment.dart';
|
||||
import 'file_uploader.dart';
|
||||
import 'messages.dart';
|
||||
import 'upload_notification_service.dart';
|
||||
|
||||
export 'upload_notification_service.dart' show UploadKind;
|
||||
|
||||
sealed class UploadJobEvent {
|
||||
const UploadJobEvent({
|
||||
required this.chatId,
|
||||
required this.tempId,
|
||||
required this.kind,
|
||||
required this.scheduled,
|
||||
});
|
||||
|
||||
final int chatId;
|
||||
final String tempId;
|
||||
final UploadKind kind;
|
||||
final bool scheduled;
|
||||
}
|
||||
|
||||
class UploadJobDone extends UploadJobEvent {
|
||||
const UploadJobDone({
|
||||
required super.chatId,
|
||||
required super.tempId,
|
||||
required super.kind,
|
||||
required super.scheduled,
|
||||
this.message,
|
||||
this.scheduledTime,
|
||||
this.fileId,
|
||||
this.fileToken,
|
||||
});
|
||||
|
||||
final CachedMessage? message;
|
||||
final int? scheduledTime;
|
||||
final int? fileId;
|
||||
final String? fileToken;
|
||||
}
|
||||
|
||||
class UploadJobFailed extends UploadJobEvent {
|
||||
const UploadJobFailed({
|
||||
required super.chatId,
|
||||
required super.tempId,
|
||||
required super.kind,
|
||||
required super.scheduled,
|
||||
required this.reason,
|
||||
});
|
||||
|
||||
final String reason;
|
||||
}
|
||||
|
||||
class UploadFailure implements Exception {
|
||||
const UploadFailure(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'UploadFailure($message)';
|
||||
}
|
||||
|
||||
class UploadBytes {
|
||||
const UploadBytes(this.sent, this.total);
|
||||
|
||||
final int sent;
|
||||
final int total;
|
||||
}
|
||||
|
||||
class UploadJob {
|
||||
UploadJob._({
|
||||
required this.id,
|
||||
required this.accountId,
|
||||
required this.chatId,
|
||||
required this.kind,
|
||||
required int slots,
|
||||
this.filename,
|
||||
this.totalBytes = 0,
|
||||
this.placeholder,
|
||||
this.scheduledTime,
|
||||
}) : _slots = slots < 1 ? 1 : slots,
|
||||
_sent = List<int>.filled(slots < 1 ? 1 : slots, 0),
|
||||
_total = List<int>.filled(slots < 1 ? 1 : slots, 0),
|
||||
progress = ValueNotifier<List<double>>(
|
||||
List<double>.filled(slots < 1 ? 1 : slots, 0),
|
||||
),
|
||||
bytes = ValueNotifier<UploadBytes>(UploadBytes(0, totalBytes)) {
|
||||
if (_slots == 1 && totalBytes > 0) _total[0] = totalBytes;
|
||||
}
|
||||
|
||||
final String id;
|
||||
final int accountId;
|
||||
final int chatId;
|
||||
final UploadKind kind;
|
||||
final String? filename;
|
||||
final int totalBytes;
|
||||
final CachedMessage? placeholder;
|
||||
final int? scheduledTime;
|
||||
|
||||
final ValueNotifier<List<double>> progress;
|
||||
final ValueNotifier<UploadBytes> bytes;
|
||||
|
||||
int? resultFileId;
|
||||
String? resultFileToken;
|
||||
|
||||
final int _slots;
|
||||
final List<int> _sent;
|
||||
final List<int> _total;
|
||||
|
||||
bool get scheduled => scheduledTime != null;
|
||||
int get slots => _slots;
|
||||
|
||||
void report(int slot, int sent, int total) {
|
||||
if (slot < 0 || slot >= _slots) return;
|
||||
_sent[slot] = sent;
|
||||
if (total > 0) _total[slot] = total;
|
||||
_publish();
|
||||
}
|
||||
|
||||
void resetSlot(int slot) {
|
||||
if (slot < 0 || slot >= _slots) return;
|
||||
_sent[slot] = 0;
|
||||
_publish();
|
||||
}
|
||||
|
||||
void markUploaded() {
|
||||
for (var i = 0; i < _slots; i++) {
|
||||
if (_total[i] <= 0) _total[i] = _sent[i] > 0 ? _sent[i] : 1;
|
||||
_sent[i] = _total[i];
|
||||
}
|
||||
_publish();
|
||||
}
|
||||
|
||||
void _publish() {
|
||||
final fractions = List<double>.generate(_slots, (i) {
|
||||
if (_total[i] <= 0) return 0.0;
|
||||
return (_sent[i] / _total[i]).clamp(0.0, 1.0);
|
||||
});
|
||||
var sumSent = 0;
|
||||
var sumTotal = 0;
|
||||
var fractionSum = 0.0;
|
||||
for (var i = 0; i < _slots; i++) {
|
||||
sumSent += _sent[i];
|
||||
sumTotal += _total[i];
|
||||
fractionSum += fractions[i];
|
||||
}
|
||||
progress.value = fractions;
|
||||
bytes.value = UploadBytes(sumSent, sumTotal);
|
||||
UploadNotificationService.report(
|
||||
id,
|
||||
sent: sumSent,
|
||||
total: sumTotal,
|
||||
fraction: fractionSum / _slots,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UploadService {
|
||||
UploadService._();
|
||||
|
||||
static final UploadService instance = UploadService._();
|
||||
|
||||
static const int _historyLimit = 60;
|
||||
static const int _photoConcurrency = 3;
|
||||
static const int _photoAttempts = 3;
|
||||
|
||||
final StreamController<UploadJobEvent> _events =
|
||||
StreamController<UploadJobEvent>.broadcast();
|
||||
|
||||
final Map<String, UploadJob> _jobs = {};
|
||||
final Map<String, CachedMessage> _completed = {};
|
||||
final Set<String> _failed = {};
|
||||
|
||||
int _tempIdCounter = 0;
|
||||
|
||||
Stream<UploadJobEvent> get events => _events.stream;
|
||||
|
||||
String newTempId() =>
|
||||
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
||||
|
||||
UploadJob? job(String tempId) => _jobs[tempId];
|
||||
|
||||
ValueListenable<List<double>>? progressFor(String tempId) =>
|
||||
_jobs[tempId]?.progress;
|
||||
|
||||
UploadJob? activeFileJob(int chatId) {
|
||||
for (final job in _jobs.values) {
|
||||
if (job.chatId == chatId && job.kind == UploadKind.file) return job;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<CachedMessage> pendingFor(int chatId) {
|
||||
final pending = <CachedMessage>[];
|
||||
for (final job in _jobs.values) {
|
||||
final placeholder = job.placeholder;
|
||||
if (job.chatId == chatId && placeholder != null) pending.add(placeholder);
|
||||
}
|
||||
return List<CachedMessage>.unmodifiable(pending);
|
||||
}
|
||||
|
||||
CachedMessage? completedFor(String tempId) => _completed[tempId];
|
||||
|
||||
bool didFail(String tempId) => _failed.contains(tempId);
|
||||
|
||||
Future<void> sendPhotos({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required List<({File file, GalleryItem? item})> jobs,
|
||||
required String caption,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
UploadJob._(
|
||||
id: tempId,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
kind: UploadKind.photo,
|
||||
slots: jobs.length,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
),
|
||||
(job) async {
|
||||
final tokens = await _uploadPhotos(jobs, job);
|
||||
if (tokens.any((token) => token == null)) {
|
||||
throw const UploadFailure('upload_failed');
|
||||
}
|
||||
final sent = await messagesModule.sendPhotoMessage(
|
||||
chatId,
|
||||
tokens.cast<String>(),
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
if (sent == null) return null;
|
||||
return CachedMessage.fromPushPayload(accountId, chatId, sent);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendVideo({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File file,
|
||||
required String caption,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
UploadJob._(
|
||||
id: tempId,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
kind: UploadKind.video,
|
||||
slots: 1,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
),
|
||||
(job) async {
|
||||
final info = await messagesModule.requestVideoUploadUrl();
|
||||
if (info == null || info.url.isEmpty) {
|
||||
throw const UploadFailure('no_upload_url');
|
||||
}
|
||||
final ok = await fileUploader.uploadVideoFile(
|
||||
Uri.parse(info.url),
|
||||
file,
|
||||
onProgress: (sent, total) => job.report(0, sent, total),
|
||||
);
|
||||
if (!ok) throw const UploadFailure('upload_failed');
|
||||
job.markUploaded();
|
||||
final sent = await messagesModule.sendVideoMessage(
|
||||
chatId,
|
||||
info.token,
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
if (sent == null) return null;
|
||||
return CachedMessage.fromPushPayload(accountId, chatId, sent);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendFile({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File source,
|
||||
required String filename,
|
||||
required int size,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
UploadJob._(
|
||||
id: tempId,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
kind: UploadKind.file,
|
||||
slots: 1,
|
||||
filename: filename,
|
||||
totalBytes: size,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
),
|
||||
(job) async {
|
||||
final done = await _uploadFile(
|
||||
chatId: chatId,
|
||||
job: job,
|
||||
source: source,
|
||||
filename: filename,
|
||||
size: size,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
FileHistoryCache.add(
|
||||
FileHistoryEntry(
|
||||
fileId: done.fileId,
|
||||
url: done.url,
|
||||
token: done.token,
|
||||
filename: done.filename,
|
||||
size: done.size,
|
||||
sentAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
job.resultFileId = done.fileId;
|
||||
job.resultFileToken = done.token;
|
||||
if (scheduledTime != null) return null;
|
||||
|
||||
final base = placeholder;
|
||||
return CachedMessage(
|
||||
id: done.messageId == null || done.messageId!.isEmpty
|
||||
? tempId
|
||||
: done.messageId!,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
senderId: accountId,
|
||||
text: base?.text,
|
||||
time: base?.time ?? DateTime.now().millisecondsSinceEpoch,
|
||||
status: 'sent',
|
||||
attachments: [
|
||||
FileAttachment(
|
||||
fileId: done.fileId,
|
||||
fileToken: done.token,
|
||||
name: filename,
|
||||
size: size,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<UploadDone> _uploadFile({
|
||||
required int chatId,
|
||||
required UploadJob job,
|
||||
required File source,
|
||||
required String filename,
|
||||
required int size,
|
||||
int? scheduledTime,
|
||||
}) async {
|
||||
final result = Completer<UploadDone>();
|
||||
late final StreamSubscription<UploadEvent> sub;
|
||||
sub = fileUploader
|
||||
.upload(
|
||||
chatId: chatId,
|
||||
file: source,
|
||||
filename: filename,
|
||||
totalSize: size,
|
||||
scheduledTime: scheduledTime,
|
||||
)
|
||||
.listen(
|
||||
(event) {
|
||||
switch (event) {
|
||||
case UploadProgress(:final sent, :final total):
|
||||
job.report(0, sent, total);
|
||||
case UploadDone():
|
||||
if (!result.isCompleted) result.complete(event);
|
||||
case UploadError(:final message):
|
||||
if (!result.isCompleted) {
|
||||
result.completeError(UploadFailure(message));
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (Object e) {
|
||||
if (!result.isCompleted) result.completeError(e);
|
||||
},
|
||||
onDone: () {
|
||||
if (!result.isCompleted) {
|
||||
result.completeError(const UploadFailure('upload_failed'));
|
||||
}
|
||||
},
|
||||
);
|
||||
try {
|
||||
return await result.future;
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run(
|
||||
UploadJob job,
|
||||
Future<CachedMessage?> Function(UploadJob job) upload,
|
||||
) async {
|
||||
_jobs[job.id] = job;
|
||||
UploadNotificationService.begin(
|
||||
job.id,
|
||||
kind: job.kind,
|
||||
count: job.slots,
|
||||
filename: job.filename,
|
||||
);
|
||||
|
||||
CachedMessage? real;
|
||||
String? failure;
|
||||
try {
|
||||
real = await upload(job);
|
||||
if (real == null && !job.scheduled) failure = 'send_failed';
|
||||
} catch (e) {
|
||||
failure = e is UploadFailure ? e.message : e.toString();
|
||||
}
|
||||
|
||||
UploadNotificationService.end(job.id);
|
||||
_jobs.remove(job.id);
|
||||
|
||||
if (failure != null) {
|
||||
logger.w('UploadService: ${job.id} — $failure');
|
||||
if (!job.scheduled) {
|
||||
_replaceInSessionCache(job.accountId, job.chatId, job.id, null);
|
||||
_remember(job.id, null);
|
||||
}
|
||||
_events.add(
|
||||
UploadJobFailed(
|
||||
chatId: job.chatId,
|
||||
tempId: job.id,
|
||||
kind: job.kind,
|
||||
scheduled: job.scheduled,
|
||||
reason: failure,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.scheduled) {
|
||||
_events.add(
|
||||
UploadJobDone(
|
||||
chatId: job.chatId,
|
||||
tempId: job.id,
|
||||
kind: job.kind,
|
||||
scheduled: true,
|
||||
scheduledTime: job.scheduledTime,
|
||||
fileId: job.resultFileId,
|
||||
fileToken: job.resultFileToken,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final message = real!;
|
||||
try {
|
||||
await AppDatabase.saveMessages([message.toDbRow()]);
|
||||
if (message.id != job.id) {
|
||||
await AppDatabase.deleteMessage(job.accountId, job.chatId, job.id);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('UploadService: не удалось сохранить сообщение: $e');
|
||||
}
|
||||
_replaceInSessionCache(job.accountId, job.chatId, job.id, message);
|
||||
_remember(job.id, message);
|
||||
_events.add(
|
||||
UploadJobDone(
|
||||
chatId: job.chatId,
|
||||
tempId: job.id,
|
||||
kind: job.kind,
|
||||
scheduled: false,
|
||||
message: message,
|
||||
fileId: job.resultFileId,
|
||||
fileToken: job.resultFileToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _remember(String tempId, CachedMessage? real) {
|
||||
if (_completed.length + _failed.length > _historyLimit) {
|
||||
_completed.clear();
|
||||
_failed.clear();
|
||||
}
|
||||
if (real == null) {
|
||||
_failed.add(tempId);
|
||||
} else {
|
||||
_completed[tempId] = real;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String?>> _uploadPhotos(
|
||||
List<({File file, GalleryItem? item})> jobs,
|
||||
UploadJob job,
|
||||
) async {
|
||||
final tokens = List<String?>.filled(jobs.length, null);
|
||||
var nextIndex = 0;
|
||||
var failed = false;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (!failed) {
|
||||
final i = nextIndex++;
|
||||
if (i >= jobs.length) return;
|
||||
final token = await _uploadOnePhoto(jobs[i], i, job);
|
||||
if (token == null) {
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
tokens[i] = token;
|
||||
}
|
||||
}
|
||||
|
||||
final workerCount = jobs.length < _photoConcurrency
|
||||
? jobs.length
|
||||
: _photoConcurrency;
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
Future<String?> _uploadOnePhoto(
|
||||
({File file, GalleryItem? item}) photo,
|
||||
int index,
|
||||
UploadJob job,
|
||||
) async {
|
||||
File file;
|
||||
try {
|
||||
file = await optimizePhotoForUpload(photo.file, item: photo.item);
|
||||
} catch (e) {
|
||||
logger.w('optimize photo: $e');
|
||||
file = photo.file;
|
||||
}
|
||||
for (var attempt = 0; attempt < _photoAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await Future.delayed(Duration(seconds: attempt));
|
||||
job.resetSlot(index);
|
||||
}
|
||||
try {
|
||||
final url = await messagesModule.requestPhotoUploadUrl();
|
||||
if (url == null || url.isEmpty) continue;
|
||||
final token = await fileUploader.uploadPhoto(
|
||||
Uri.parse(url),
|
||||
file,
|
||||
filename: _photoFilename(file),
|
||||
onProgress: (sent, total) => job.report(index, sent, total),
|
||||
);
|
||||
if (token != null) return token;
|
||||
} catch (e) {
|
||||
logger.w('uploadOnePhoto attempt ${attempt + 1}: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _photoFilename(File file) {
|
||||
final segments = file.uri.pathSegments;
|
||||
final name = segments.isNotEmpty ? segments.last : '';
|
||||
return name.isNotEmpty ? name : 'photo.jpg';
|
||||
}
|
||||
|
||||
void _replaceInSessionCache(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String tempId,
|
||||
CachedMessage? real,
|
||||
) {
|
||||
final cached = MessageSessionCache.get(accountId, chatId);
|
||||
if (cached == null) return;
|
||||
final list = List<CachedMessage>.of(cached.messages);
|
||||
final idx = list.indexWhere((m) => m.id == tempId);
|
||||
if (idx == -1) return;
|
||||
list[idx] = real ?? list[idx].copyWith(status: 'error');
|
||||
MessageSessionCache.save(
|
||||
accountId,
|
||||
chatId,
|
||||
list,
|
||||
reachedStart: cached.reachedStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user