feat/fix/refactor: Кнопка добавления человека в контакты, возможность развертывания аватарки на экране профиля, включение сторисов по умолчанию, показ сторисов на экране чатов, чата, и при просмотре списка участников, миниапы у ботов. Фикс прогресса просмотра сториса, фикс предпросмотра медиав пикере, прогресс бар отправки фото/видео/файлов + фото продолжат отправку даже если выйти с чата. Вынес превью фото, добавил отдельный модуль отправки медиа

This commit is contained in:
Jganenokk
2026-08-05 20:04:12 +07:00
parent e87d3d57f7
commit 3915cb957e
33 changed files with 2522 additions and 680 deletions
+7 -1
View File
@@ -465,6 +465,10 @@ class ContactsModule {
}
}
static final Map<int, ContactPhotos> _photosHead = {};
static ContactPhotos? cachedPhotos(int contactId) => _photosHead[contactId];
static Future<ContactPhotos> fetchPhotos(
Api api,
int contactId, {
@@ -482,7 +486,9 @@ class ContactsModule {
? rawUrls.whereType<String>().toList()
: <String>[];
final total = map['total'] is int ? map['total'] as int : urls.length;
return ContactPhotos(urls: urls, total: total);
final photos = ContactPhotos(urls: urls, total: total);
if (from == 0) _photosHead[contactId] = photos;
return photos;
}
static Future<List<CachedContact>> getContacts(int accountId) async {
+338
View File
@@ -0,0 +1,338 @@
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,
);
}
}
+2
View File
@@ -297,6 +297,8 @@ class ReplyInfo {
this.attachments,
});
bool get missing => previewText().isEmpty;
static ReplyInfo? fromPayload(Map<String, dynamic>? payload) {
if (payload == null) return null;
final link = payload['link'];
+114 -1
View File
@@ -123,6 +123,7 @@ class StoriesModule {
if (acc == null) return;
final map = <String, dynamic>{};
_peerStories.forEach((ownerId, stories) {
if (!_previews.containsKey(ownerId)) return;
map['$ownerId'] = stories.map((s) => s.toJson()).toList();
});
await AppDatabase.setSyncValue(acc, _peersKey, jsonEncode(map));
@@ -147,6 +148,11 @@ class StoriesModule {
int? lastViewedStoryId(int ownerId) => _lastViewed[ownerId];
void clearLastViewed(int ownerId) {
if (_lastViewed.remove(ownerId) == null) return;
unawaited(_persistProgress());
}
/// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени.
List<StoryPreview> get previews {
final list = _previews.values.where((p) => !p.isEmpty).toList();
@@ -161,6 +167,83 @@ class StoriesModule {
StoryPreview? previewFor(int ownerId) => _previews[ownerId];
final Map<int, StoryPreview> _peerPreviews = {};
final Set<int> _requestedOwners = {};
static const int _ownersChunk = 20;
StoryPreview? previewOf(int ownerId) {
final feed = _previews[ownerId];
if (feed != null) return feed.isEmpty ? null : feed;
final peer = _peerPreviews[ownerId];
return (peer == null || peer.isEmpty) ? null : peer;
}
Future<void> loadOwnersPreviews(List<int> ownerIds) async {
if (_api.state != SessionState.online) return;
final missing = ownerIds
.where((id) => id > 0 && !_requestedOwners.contains(id))
.toSet()
.toList();
if (missing.isEmpty) return;
_requestedOwners.addAll(missing);
var changed = false;
for (var i = 0; i < missing.length; i += _ownersChunk) {
final end = i + _ownersChunk > missing.length
? missing.length
: i + _ownersChunk;
final chunk = missing.sublist(i, end);
try {
final packet = await _api.sendRequest(Opcode.storiesGetByOwner, {
'owners': [
for (final id in chunk) StoryOwner(ownerId: id).toMap(),
],
}, silent: true);
if (packet.isError) continue;
if (_applyOwnerPayload(packet.payload, chunk)) changed = true;
} catch (e) {
logger.w('StoriesModule.loadOwnersPreviews: $e');
}
}
if (changed) _bump();
}
bool _applyOwnerPayload(Object? data, List<int> requested) {
if (data is! Map) return false;
var changed = false;
final seen = <int>{};
final rawPreviews = data['storiesPreviews'];
if (rawPreviews is List) {
for (final raw in rawPreviews) {
final preview = StoryPreview.fromMap(raw);
if (preview == null) continue;
final id = preview.owner.ownerId;
seen.add(id);
if (preview.isEmpty) {
_peerPreviews.remove(id);
} else {
_peerPreviews[id] = preview;
}
_refreshPreview(preview);
changed = true;
}
}
for (final id in requested) {
if (!seen.contains(id) && _peerPreviews.remove(id) != null) changed = true;
}
final rawPeers = data['peerStories'];
if (rawPeers is List) {
for (final raw in rawPeers) {
final peer = PeerStories.fromMap(raw);
if (peer == null) continue;
_peerStories[peer.owner.ownerId] = peer.stories;
changed = true;
}
}
return changed;
}
List<Story>? cachedStories(int ownerId) => _peerStories[ownerId];
/// Подписка на серверные пуши обновления колец (NOTIF_STORIES_UPDATE).
@@ -180,6 +263,11 @@ class StoriesModule {
unawaited(_persistPreviews());
}
void _refreshPreview(StoryPreview preview) {
if (!_previews.containsKey(preview.owner.ownerId)) return;
_applyPreview(preview);
}
void _applyPreview(StoryPreview preview) {
if (preview.isEmpty) {
_previews.remove(preview.owner.ownerId);
@@ -234,7 +322,7 @@ class StoriesModule {
if (rawPreviews is List) {
for (final raw in rawPreviews) {
final preview = StoryPreview.fromMap(raw);
if (preview != null) _applyPreview(preview);
if (preview != null) _refreshPreview(preview);
}
}
@@ -258,6 +346,31 @@ class StoriesModule {
}
}
Future<StoryPreview?> loadOwnerPreview(StoryOwner owner) async {
final cached = _previews[owner.ownerId];
if (_api.state != SessionState.online) return cached;
try {
final packet = await _api.sendRequest(Opcode.storiesGetByOwner, {
'owners': [owner.toMap()],
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is! Map) return cached;
_applyOwnerPayload(data, [owner.ownerId]);
_requestedOwners.add(owner.ownerId);
final own = previewOf(owner.ownerId);
_bump();
unawaited(_persistPreviews());
unawaited(_persistPeers());
return own;
} catch (e) {
logger.w('StoriesModule.loadOwnerPreview: $e');
return cached;
}
}
/// Отметить историю просмотренной. Оптимистично поднимает readCount кольца.
Future<bool> mark(StoryOwner owner, int storyId) async {
if (_api.state != SessionState.online) return false;
+5
View File
@@ -13,6 +13,11 @@ abstract class EntryBannerApps {
};
}
const Set<String> kMiniAppOptions = {'HAS_WEBAPP', 'HAS_WEB_APP', 'WEBAPP'};
bool hasMiniAppOption(Set<String>? options) =>
options != null && options.any(kMiniAppOptions.contains);
class WebAppLaunch {
final String url;