Files

330 lines
8.9 KiB
Dart

import '../../core/protocol/opcode_map.dart';
import '../../core/utils/logger.dart';
import '../../models/attachment.dart';
import '../api.dart';
const Map<String, AttachmentType> _attachTypeByName = {
'PHOTO': AttachmentType.photo,
'VIDEO': AttachmentType.video,
'AUDIO': AttachmentType.audio,
'FILE': AttachmentType.file,
'SHARE': AttachmentType.share,
};
class SharedMediaItem {
final String messageId;
final int chatId;
final int senderId;
final int time;
final MessageAttachment attachment;
final String? text;
const SharedMediaItem({
required this.messageId,
required this.chatId,
required this.senderId,
required this.time,
required this.attachment,
this.text,
});
String get dedupKey {
final a = attachment;
final String tail;
if (a is PhotoAttachment) {
tail = 'p${a.photoId ?? a.baseUrl}';
} else if (a is VideoAttachment) {
tail = 'v${a.videoId ?? a.baseUrl}';
} else if (a is FileAttachment) {
tail = 'f${a.fileId ?? a.name}';
} else if (a is AudioAttachment) {
tail = 'a${a.audioId ?? a.fileUrl}';
} else if (a is ShareAttachment) {
tail = 's${a.shareId ?? a.url}';
} else {
tail = a.hashCode.toString();
}
return '$messageId:$tail';
}
}
class SharedMediaPage {
final List<SharedMediaItem> items;
final int total;
const SharedMediaPage({required this.items, required this.total});
static const empty = SharedMediaPage(items: [], total: 0);
}
class CommonChatEntry {
final int id;
final String type;
final String title;
final String? iconUrl;
final int participantsCount;
final List<int> participantIds;
const CommonChatEntry({
required this.id,
required this.type,
required this.title,
required this.iconUrl,
required this.participantsCount,
required this.participantIds,
});
factory CommonChatEntry.fromMap(Map<String, dynamic> map) {
final participants = map['participants'];
final ids = <int>[];
if (participants is Map) {
for (final key in participants.keys) {
final id = key is int ? key : int.tryParse(key.toString());
if (id != null) ids.add(id);
}
}
return CommonChatEntry(
id: (map['id'] as num?)?.toInt() ?? 0,
type: map['type']?.toString() ?? 'CHAT',
title: map['title']?.toString() ?? '',
iconUrl: map['baseIconUrl'] as String?,
participantsCount:
(map['participantsCount'] as num?)?.toInt() ?? ids.length,
participantIds: ids,
);
}
}
class ChatMediaFeed {
final List<SharedMediaItem> items;
final int total;
final bool reachedEnd;
const ChatMediaFeed({
required this.items,
required this.total,
required this.reachedEnd,
});
}
class _ChatMediaIndex {
final List<SharedMediaItem> items = [];
final Set<String> seen = {};
int total = 0;
bool reachedEnd = false;
bool started = false;
Future<void>? inFlight;
}
String mediaDedupKey(String messageId, MessageAttachment attachment) {
if (attachment is PhotoAttachment) {
return '$messageId:p${attachment.photoId ?? attachment.baseUrl}';
}
if (attachment is VideoAttachment) {
return '$messageId:v${attachment.videoId ?? attachment.baseUrl}';
}
return '$messageId:${attachment.hashCode}';
}
class SharedContentModule {
static const int _mediaIndexPageSize = 60;
static const int _mediaIndexMaxPages = 40;
static final Map<int, _ChatMediaIndex> _mediaIndexes = {};
final Api _api;
SharedContentModule(this._api);
static void clearMediaIndex() => _mediaIndexes.clear();
Future<ChatMediaFeed?> mediaFeedFor({
required int chatId,
required String mediaKey,
required Future<String?> Function() resolveAnchor,
}) async {
final index = _mediaIndexes.putIfAbsent(chatId, _ChatMediaIndex.new);
for (var page = 0; page < _mediaIndexMaxPages; page++) {
if (index.seen.contains(mediaKey)) return _snapshot(index);
if (index.reachedEnd) return null;
await _nextMediaPage(chatId, index, resolveAnchor);
}
return null;
}
Future<ChatMediaFeed> loadMoreMedia({
required int chatId,
required Future<String?> Function() resolveAnchor,
}) async {
final index = _mediaIndexes.putIfAbsent(chatId, _ChatMediaIndex.new);
if (!index.reachedEnd) {
await _nextMediaPage(chatId, index, resolveAnchor);
}
return _snapshot(index);
}
ChatMediaFeed _snapshot(_ChatMediaIndex index) {
final counted = index.items.length;
final total = index.reachedEnd
? counted
: (index.total > counted ? index.total : counted);
return ChatMediaFeed(
items: List.unmodifiable(index.items),
total: total,
reachedEnd: index.reachedEnd,
);
}
Future<void> _nextMediaPage(
int chatId,
_ChatMediaIndex index,
Future<String?> Function() resolveAnchor,
) async {
final pending = index.inFlight;
if (pending != null) {
await pending;
return;
}
final task = _loadMediaPage(chatId, index, resolveAnchor);
index.inFlight = task;
try {
await task;
} finally {
index.inFlight = null;
}
}
Future<void> _loadMediaPage(
int chatId,
_ChatMediaIndex index,
Future<String?> Function() resolveAnchor,
) async {
final initial = !index.started;
final anchor = initial ? await resolveAnchor() : index.items.last.messageId;
if (anchor == null || anchor.isEmpty) {
index.reachedEnd = true;
return;
}
final page = await fetchMedia(
chatId: chatId,
anchorMessageId: anchor,
attachTypes: const ['PHOTO', 'VIDEO'],
forward: initial ? _mediaIndexPageSize : 0,
backward: _mediaIndexPageSize,
);
index.started = true;
if (page.total > index.total) index.total = page.total;
final fresh = <SharedMediaItem>[];
for (final item in page.items) {
if (index.seen.add(item.dedupKey)) fresh.add(item);
}
if (fresh.isEmpty) {
index.reachedEnd = true;
return;
}
final oldest = index.items.isEmpty ? null : index.items.last;
index.items.addAll(fresh);
if (oldest != null && fresh.first.time > oldest.time) {
index.items.sort((a, b) => b.time.compareTo(a.time));
}
}
Future<SharedMediaPage> fetchMedia({
required int chatId,
required String anchorMessageId,
required List<String> attachTypes,
int forward = 0,
int backward = 60,
}) async {
try {
final response = await _api.sendRequest(Opcode.chatMedia, {
'chatId': chatId,
'messageId': int.tryParse(anchorMessageId) ?? 0,
'attachTypes': attachTypes,
'forward': forward,
'backward': backward,
});
if (!response.isOk) return SharedMediaPage.empty;
final data = response.payload;
if (data is! Map) return SharedMediaPage.empty;
final messages = data['messages'];
if (messages is! List) return SharedMediaPage.empty;
final wanted = attachTypes
.map((t) => _attachTypeByName[t])
.whereType<AttachmentType>()
.toSet();
final out = <SharedMediaItem>[];
for (final m in messages) {
if (m is! Map) continue;
final map = Map<String, dynamic>.from(m);
final id = map['id']?.toString();
if (id == null) continue;
final sender = (map['sender'] as num?)?.toInt() ?? 0;
final time = (map['time'] as num?)?.toInt() ?? 0;
final text = map['text'] as String?;
final attaches = map['attaches'];
if (attaches is! List) continue;
for (final a in attaches) {
if (a is! Map) continue;
final att = MessageAttachment.fromMap(Map<String, dynamic>.from(a));
if (!wanted.contains(att.type)) continue;
out.add(
SharedMediaItem(
messageId: id,
chatId: chatId,
senderId: sender,
time: time,
attachment: att,
text: text,
),
);
}
}
out.sort((a, b) => b.time.compareTo(a.time));
final total = (data['total'] as num?)?.toInt() ?? out.length;
return SharedMediaPage(items: out, total: total);
} catch (e) {
logger.w('SharedContent.fetchMedia failed: $e');
return SharedMediaPage.empty;
}
}
Future<List<CommonChatEntry>> fetchCommonChats(int userId) async {
try {
final response = await _api.sendRequest(
Opcode.chatSearchCommonParticipants,
{
'userIds': [userId],
},
);
if (!response.isOk) return const [];
final data = response.payload;
if (data is! Map) return const [];
final chats = data['commonChats'];
if (chats is! List) return const [];
final out = <CommonChatEntry>[];
for (final c in chats) {
if (c is Map) {
out.add(CommonChatEntry.fromMap(Map<String, dynamic>.from(c)));
}
}
return out;
} catch (e) {
logger.w('SharedContent.fetchCommonChats failed: $e');
return const [];
}
}
}