feat/fix/refactor: Кнопка добавления человека в контакты, возможность развертывания аватарки на экране профиля, включение сторисов по умолчанию, показ сторисов на экране чатов, чата, и при просмотре списка участников, миниапы у ботов. Фикс прогресса просмотра сториса, фикс предпросмотра медиав пикере, прогресс бар отправки фото/видео/файлов + фото продолжат отправку даже если выйти с чата. Вынес превью фото, добавил отдельный модуль отправки медиа
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user