Переход к медиа на экране профиля, часть issue #45
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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;
|
||||
|
||||
const SharedMediaItem({
|
||||
required this.messageId,
|
||||
required this.chatId,
|
||||
required this.senderId,
|
||||
required this.time,
|
||||
required this.attachment,
|
||||
});
|
||||
|
||||
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 SharedContentModule {
|
||||
final Api _api;
|
||||
|
||||
SharedContentModule(this._api);
|
||||
|
||||
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 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,52 @@ Future<MediaSaveResult> saveImageFromUrl(String url) async {
|
||||
}
|
||||
}
|
||||
|
||||
enum SaveMediaKind { image, video, file }
|
||||
|
||||
Future<MediaSaveResult> saveMediaFile({
|
||||
required String cacheName,
|
||||
required Future<String?> Function() resolveUrl,
|
||||
required String saveName,
|
||||
required SaveMediaKind kind,
|
||||
}) async {
|
||||
try {
|
||||
var file = await MediaCache.existing(cacheName);
|
||||
if (file == null) {
|
||||
final url = await resolveUrl();
|
||||
if (url == null || url.isEmpty) {
|
||||
return const MediaSaveResult(ok: false, error: 'нет ссылки');
|
||||
}
|
||||
file = await MediaCache.getOrDownload(cacheName, url);
|
||||
}
|
||||
if (file == null) {
|
||||
return const MediaSaveResult(ok: false, error: 'не удалось загрузить');
|
||||
}
|
||||
|
||||
final toGallery =
|
||||
kind == SaveMediaKind.image || kind == SaveMediaKind.video;
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && toGallery) {
|
||||
final state = await PhotoManager.requestPermissionExtend();
|
||||
if (!state.isAuth && !state.hasAccess) {
|
||||
return const MediaSaveResult(ok: false, error: 'нет доступа к галерее');
|
||||
}
|
||||
if (kind == SaveMediaKind.video) {
|
||||
await PhotoManager.editor.saveVideo(file, title: saveName);
|
||||
} else {
|
||||
final bytes = await file.readAsBytes();
|
||||
await PhotoManager.editor.saveImage(bytes, filename: saveName);
|
||||
}
|
||||
return const MediaSaveResult(ok: true, toGallery: true);
|
||||
}
|
||||
|
||||
final dir = await _targetDirectory();
|
||||
final target = File('${dir.path}${Platform.pathSeparator}$saveName');
|
||||
await file.copy(target.path);
|
||||
return MediaSaveResult(ok: true, location: target.path);
|
||||
} catch (e) {
|
||||
return MediaSaveResult(ok: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _targetDirectory() async {
|
||||
try {
|
||||
final downloads = await getDownloadsDirectory();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:komet/main.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../backend/modules/messages.dart' show ContactCache;
|
||||
import '../../../core/cache/info_cache.dart';
|
||||
@@ -11,6 +12,7 @@ import '../../../l10n/app_localizations.dart';
|
||||
import '../../../models/chat_info.dart';
|
||||
import '../../../models/contact_info.dart';
|
||||
import '../../widgets/avatar_history_screen.dart';
|
||||
import '../../widgets/chat_info/shared_content_tabs.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
@@ -43,6 +45,8 @@ class ChatInfoScreen extends StatefulWidget {
|
||||
|
||||
final int? dialogPeerId;
|
||||
|
||||
final void Function(String messageId, int time)? onJumpToMessage;
|
||||
|
||||
const ChatInfoScreen({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
@@ -50,6 +54,7 @@ class ChatInfoScreen extends StatefulWidget {
|
||||
required this.imageUrl,
|
||||
required this.chatType,
|
||||
this.dialogPeerId,
|
||||
this.onJumpToMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -76,6 +81,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
List<_MemberInfo> _members = [];
|
||||
int _onlineCount = 0;
|
||||
|
||||
int _mediaChatId = 0;
|
||||
String? _anchorMsgId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -140,6 +148,23 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
if (!mounted) return;
|
||||
_chatInfo = info;
|
||||
|
||||
_mediaChatId = (info?.raw['id'] as int?) ?? widget.chatId;
|
||||
final lastMessage = info?.raw['lastMessage'];
|
||||
if (lastMessage is Map) {
|
||||
_anchorMsgId = lastMessage['id']?.toString();
|
||||
}
|
||||
if (_anchorMsgId == null && info != null) {
|
||||
try {
|
||||
final recent = await messagesModule.fetchHistory(
|
||||
_myId,
|
||||
_mediaChatId,
|
||||
count: 1,
|
||||
);
|
||||
if (recent.isNotEmpty) _anchorMsgId = recent.first.id;
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
_otherId = widget.dialogPeerId;
|
||||
if (_otherId == null && info != null) {
|
||||
@@ -671,27 +696,94 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
return _buildMembersTabContent(cs);
|
||||
}
|
||||
if (_selectedTab == l10n.chatInfoTabGeneralChats) {
|
||||
return _buildPlaceholder(cs, l10n.chatInfoEmptyGeneralChats, Icons.group);
|
||||
final peerId = _otherId;
|
||||
if (peerId == null) {
|
||||
return _buildPlaceholder(
|
||||
cs,
|
||||
l10n.chatInfoEmptyGeneralChats,
|
||||
Icons.group,
|
||||
);
|
||||
}
|
||||
return CommonChatsTab(
|
||||
key: const ValueKey('tab-common-chats'),
|
||||
userId: peerId,
|
||||
emptyLabel: l10n.chatInfoEmptyGeneralChats,
|
||||
);
|
||||
}
|
||||
if (_selectedTab == l10n.chatInfoTabMedia) {
|
||||
return _buildPlaceholder(
|
||||
return _sharedTab(
|
||||
cs,
|
||||
SharedContentKind.media,
|
||||
l10n.chatInfoEmptyMedia,
|
||||
Icons.photo_library,
|
||||
);
|
||||
}
|
||||
if (_selectedTab == l10n.chatInfoTabFiles) {
|
||||
return _buildPlaceholder(cs, l10n.chatInfoEmptyFiles, Icons.description);
|
||||
return _sharedTab(
|
||||
cs,
|
||||
SharedContentKind.files,
|
||||
l10n.chatInfoEmptyFiles,
|
||||
Icons.description,
|
||||
);
|
||||
}
|
||||
if (_selectedTab == l10n.chatInfoTabVoice) {
|
||||
return _buildPlaceholder(cs, l10n.chatInfoEmptyVoice, Icons.mic);
|
||||
return _sharedTab(
|
||||
cs,
|
||||
SharedContentKind.voice,
|
||||
l10n.chatInfoEmptyVoice,
|
||||
Icons.mic,
|
||||
);
|
||||
}
|
||||
if (_selectedTab == l10n.chatInfoTabLinks) {
|
||||
return _buildPlaceholder(cs, l10n.chatInfoEmptyLinks, Icons.link);
|
||||
return _sharedTab(
|
||||
cs,
|
||||
SharedContentKind.links,
|
||||
l10n.chatInfoEmptyLinks,
|
||||
Icons.link,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _sharedTab(
|
||||
ColorScheme cs,
|
||||
SharedContentKind kind,
|
||||
String emptyLabel,
|
||||
IconData emptyIcon,
|
||||
) {
|
||||
final anchor = _anchorMsgId;
|
||||
if (anchor == null) return _buildPlaceholder(cs, emptyLabel, emptyIcon);
|
||||
return SharedMediaTab(
|
||||
key: ValueKey('tab-shared-$kind'),
|
||||
chatId: _mediaChatId,
|
||||
anchorMessageId: anchor,
|
||||
myId: _myId,
|
||||
kind: kind,
|
||||
emptyLabel: emptyLabel,
|
||||
emptyIcon: emptyIcon,
|
||||
onGoToMessage: _goToMessage,
|
||||
);
|
||||
}
|
||||
|
||||
void _goToMessage(String messageId, int time) {
|
||||
final jumpInParent = widget.onJumpToMessage;
|
||||
if (jumpInParent != null && _mediaChatId == widget.chatId) {
|
||||
jumpInParent(messageId, time);
|
||||
return;
|
||||
}
|
||||
pushSwipeable(
|
||||
context,
|
||||
(_) => ChatScreen(
|
||||
chatId: _mediaChatId,
|
||||
name: widget.name,
|
||||
imageUrl: widget.imageUrl,
|
||||
chatType: widget.chatType,
|
||||
initialMessageId: messageId,
|
||||
initialMessageTime: time,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(ColorScheme cs, String label, IconData icon) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
|
||||
@@ -180,6 +180,8 @@ class ChatScreen extends StatefulWidget {
|
||||
final bool embedded;
|
||||
final VoidCallback? onClose;
|
||||
final ForwardRequest? forwardRequest;
|
||||
final String? initialMessageId;
|
||||
final int? initialMessageTime;
|
||||
|
||||
const ChatScreen({
|
||||
super.key,
|
||||
@@ -190,6 +192,8 @@ class ChatScreen extends StatefulWidget {
|
||||
this.embedded = false,
|
||||
this.onClose,
|
||||
this.forwardRequest,
|
||||
this.initialMessageId,
|
||||
this.initialMessageTime,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -208,8 +212,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
double _pinnedAlignment = 0;
|
||||
int? _unreadAnchorTime;
|
||||
bool _awaitingPosition = false;
|
||||
bool _navigatingToTarget = false;
|
||||
bool _initialPositionDone = false;
|
||||
bool _positioningInFlight = false;
|
||||
bool _initialTargetHandled = false;
|
||||
bool _suppressHistoryAutoload = false;
|
||||
int _readMarkTime = 0;
|
||||
Timer? _readMarkTimer;
|
||||
final GlobalKey _listKey = GlobalKey();
|
||||
@@ -275,6 +282,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
|
||||
final ValueNotifier<String?> _highlightMessageId = ValueNotifier(null);
|
||||
Timer? _highlightTimer;
|
||||
final ValueNotifier<double?> _jumpCacheExtent = ValueNotifier<double?>(null);
|
||||
Timer? _goToMessageSettleTimer;
|
||||
static const double _jumpCacheExtentPx = 800.0;
|
||||
|
||||
late final ChatSearchController _search;
|
||||
late final AnimationController _searchAnim;
|
||||
@@ -690,6 +700,46 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_isLoading = false;
|
||||
if (_shimmerController.isAnimating) _shimmerController.stop();
|
||||
_scheduleReadMarker();
|
||||
_maybeRunInitialTarget();
|
||||
}
|
||||
|
||||
void _maybeRunInitialTarget() {
|
||||
if (_initialTargetHandled || widget.initialMessageId == null) return;
|
||||
_initialTargetHandled = true;
|
||||
_beginTargetNavigation();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_navigateToInitialMessage());
|
||||
});
|
||||
}
|
||||
|
||||
void _beginTargetNavigation() {
|
||||
_navigatingToTarget = true;
|
||||
_jumpCacheExtent.value = _jumpCacheExtentPx;
|
||||
_goToMessageSettleTimer?.cancel();
|
||||
if (!_shimmerController.isAnimating) _shimmerController.repeat();
|
||||
}
|
||||
|
||||
void _finishTargetNavigation() {
|
||||
_goToMessageSettleTimer?.cancel();
|
||||
if (!mounted) {
|
||||
_navigatingToTarget = false;
|
||||
return;
|
||||
}
|
||||
if (_navigatingToTarget) {
|
||||
setState(() => _navigatingToTarget = false);
|
||||
}
|
||||
if (_shimmerController.isAnimating) _shimmerController.stop();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _jumpCacheExtent.value = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _requestGoToMessage(String id, int time) {
|
||||
if (!mounted) return;
|
||||
setState(_beginTargetNavigation);
|
||||
_goToMessageSettleTimer = Timer(const Duration(milliseconds: 340), () {
|
||||
if (mounted) unawaited(_runGoToMessage(id, time));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadUntilUnreadReady() async {
|
||||
@@ -754,7 +804,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final atBottom = candidate.id == _messages.last.id;
|
||||
|
||||
if (_unreadAnchorTime != null &&
|
||||
_unreadSeparatorScrolledPast(atBottom, topIndex, listBox, viewportBottom)) {
|
||||
_unreadSeparatorScrolledPast(
|
||||
atBottom,
|
||||
topIndex,
|
||||
listBox,
|
||||
viewportBottom,
|
||||
)) {
|
||||
_unreadAnchorTime = null;
|
||||
_bumpMessages();
|
||||
}
|
||||
@@ -1014,6 +1069,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
void _maybeLoadMoreHistory() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
if (_suppressHistoryAutoload) return;
|
||||
if (_isLoading || _isLoadingMore || !_hasMoreHistory) return;
|
||||
if (_messages.isEmpty) return;
|
||||
final pos = _scrollController.position;
|
||||
@@ -1186,6 +1242,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_replyTo.dispose();
|
||||
_highlightTimer?.cancel();
|
||||
_highlightMessageId.dispose();
|
||||
_goToMessageSettleTimer?.cancel();
|
||||
_jumpCacheExtent.dispose();
|
||||
_messageKeys.clear();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -2158,17 +2216,29 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
showCall:
|
||||
widget.chatType == 'DIALOG' && !_peerIsBot,
|
||||
onClose: widget.onClose,
|
||||
onOpenInfo: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatInfoScreen(
|
||||
chatId: widget.chatId,
|
||||
name: widget.name,
|
||||
imageUrl: widget.imageUrl,
|
||||
chatType: widget.chatType,
|
||||
onOpenInfo: () {
|
||||
final navigator = Navigator.of(context);
|
||||
final chatRoute = ModalRoute.of(context);
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatInfoScreen(
|
||||
chatId: widget.chatId,
|
||||
name: widget.name,
|
||||
imageUrl: widget.imageUrl,
|
||||
chatType: widget.chatType,
|
||||
onJumpToMessage:
|
||||
(chatRoute == null || widget.embedded)
|
||||
? null
|
||||
: (messageId, time) {
|
||||
navigator.popUntil(
|
||||
(r) => r == chatRoute,
|
||||
);
|
||||
_requestGoToMessage(messageId, time);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onOpenScheduled: _openScheduledMessages,
|
||||
onCall: _startCall,
|
||||
onMenu: _openChatMenu,
|
||||
@@ -3193,6 +3263,163 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_search.reset();
|
||||
}
|
||||
|
||||
Future<void> _navigateToInitialMessage() async {
|
||||
final id = widget.initialMessageId;
|
||||
if (id == null) {
|
||||
_finishTargetNavigation();
|
||||
return;
|
||||
}
|
||||
await _runGoToMessage(id, widget.initialMessageTime ?? 0);
|
||||
}
|
||||
|
||||
Future<void> _runGoToMessage(String id, int targetTime) async {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!mounted) return;
|
||||
|
||||
if (!_messages.any((m) => m.id == id)) {
|
||||
var guard = 0;
|
||||
while (mounted &&
|
||||
guard < 80 &&
|
||||
_hasMoreHistory &&
|
||||
!_messages.any((m) => m.id == id) &&
|
||||
(_messages.isEmpty || _messages.first.time > targetTime)) {
|
||||
guard++;
|
||||
final before = _messages.isEmpty ? 0 : _messages.first.time;
|
||||
await _loadMoreHistory();
|
||||
if (!mounted) return;
|
||||
final after = _messages.isEmpty ? 0 : _messages.first.time;
|
||||
if (after == before) break;
|
||||
}
|
||||
if (!mounted) return;
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
if (!_messages.any((m) => m.id == id)) {
|
||||
if (mounted) showCustomNotification(context, 'Сообщение не загружено');
|
||||
_finishTargetNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
_highlightTimer?.cancel();
|
||||
_highlightMessageId.value = id;
|
||||
_highlightTimer = Timer(const Duration(milliseconds: 2200), () {
|
||||
if (!mounted) return;
|
||||
if (_highlightMessageId.value == id) _highlightMessageId.value = null;
|
||||
});
|
||||
|
||||
await _scrollToMessagePrecise(id);
|
||||
_finishTargetNavigation();
|
||||
}
|
||||
|
||||
Future<void> _scrollToMessagePrecise(
|
||||
String id, {
|
||||
double alignment = 0.32,
|
||||
}) async {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
if (_messages.indexWhere((m) => m.id == id) == -1) return;
|
||||
|
||||
_suppressHistoryAutoload = true;
|
||||
try {
|
||||
var stable = 0;
|
||||
for (var iter = 0; iter < 48; iter++) {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
final listObj = _listKey.currentContext?.findRenderObject();
|
||||
final boxObj = _keyForMessage(id).currentContext?.findRenderObject();
|
||||
|
||||
if (boxObj is RenderBox && boxObj.attached && listObj is RenderBox) {
|
||||
final viewportH = listObj.size.height;
|
||||
final actualTop = boxObj
|
||||
.localToGlobal(Offset.zero, ancestor: listObj)
|
||||
.dy;
|
||||
final desiredTop = alignment * viewportH;
|
||||
final delta = desiredTop - actualTop;
|
||||
final p = _scrollController.position;
|
||||
final target = (p.pixels + delta).clamp(
|
||||
p.minScrollExtent,
|
||||
p.maxScrollExtent,
|
||||
);
|
||||
|
||||
if (delta.abs() <= 4.0 || (target - p.pixels).abs() <= 1.0) {
|
||||
stable++;
|
||||
if (stable >= 3) return;
|
||||
await Future.delayed(const Duration(milliseconds: 130));
|
||||
continue;
|
||||
}
|
||||
stable = 0;
|
||||
_scrollController.jumpTo(target);
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
continue;
|
||||
}
|
||||
|
||||
stable = 0;
|
||||
final items = _buildCombinedItems();
|
||||
final pos = items.indexWhere(
|
||||
(it) => it is _MessageItem && it.message.id == id,
|
||||
);
|
||||
if (pos == -1) return;
|
||||
var below = 0.0;
|
||||
for (var i = pos + 1; i < items.length; i++) {
|
||||
below += _estimatedItemExtent(items[i]);
|
||||
}
|
||||
final p = _scrollController.position;
|
||||
final maxExtent = p.maxScrollExtent;
|
||||
final viewportH = listObj is RenderBox ? listObj.size.height : 500.0;
|
||||
var targetOffset = below.clamp(0.0, maxExtent).toDouble();
|
||||
if ((targetOffset - p.pixels).abs() < 4.0) {
|
||||
targetOffset = (p.pixels + viewportH * 0.8).clamp(0.0, maxExtent);
|
||||
if ((targetOffset - p.pixels).abs() < 4.0) return;
|
||||
}
|
||||
_scrollController.jumpTo(targetOffset);
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
}
|
||||
} finally {
|
||||
_suppressHistoryAutoload = false;
|
||||
}
|
||||
}
|
||||
|
||||
double _estimatedItemExtent(Object item) {
|
||||
if (item is! _MessageItem) return 44.0;
|
||||
final msg = item.message;
|
||||
var base = 0.0;
|
||||
final atts = msg.attachments;
|
||||
if (atts != null && atts.isNotEmpty) {
|
||||
for (final a in atts) {
|
||||
switch (a.type) {
|
||||
case AttachmentType.photo:
|
||||
case AttachmentType.video:
|
||||
int? w;
|
||||
int? h;
|
||||
if (a is PhotoAttachment) {
|
||||
w = a.width;
|
||||
h = a.height;
|
||||
} else if (a is VideoAttachment) {
|
||||
w = a.width;
|
||||
h = a.height;
|
||||
}
|
||||
base += (w != null && h != null && w > 0)
|
||||
? (236.0 * h / w).clamp(120.0, 360.0)
|
||||
: 260.0;
|
||||
base += 12;
|
||||
case AttachmentType.sticker:
|
||||
base += 160;
|
||||
case AttachmentType.audio:
|
||||
base += 72;
|
||||
case AttachmentType.file:
|
||||
base += 80;
|
||||
case AttachmentType.share:
|
||||
base += 96;
|
||||
default:
|
||||
base += 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
final textLen = msg.text?.length ?? 0;
|
||||
if (textLen > 0) base += 24.0 + (textLen ~/ 34) * 20.0;
|
||||
if (base <= 0) base = _avgMessageHeight;
|
||||
return base.clamp(44.0, 1200.0).toDouble();
|
||||
}
|
||||
|
||||
Future<void> _openSearchResult(MessageSearchResult result) async {
|
||||
_closeSearch();
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
@@ -3243,7 +3470,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
final laidOut = _keyForMessage(messageId).currentContext?.findRenderObject();
|
||||
final laidOut = _keyForMessage(
|
||||
messageId,
|
||||
).currentContext?.findRenderObject();
|
||||
if (laidOut is! RenderBox || !laidOut.attached) {
|
||||
var below = 0.0;
|
||||
for (var i = pos + 1; i < items.length; i++) {
|
||||
@@ -3685,9 +3914,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
children: [
|
||||
if (_wallpaper != null)
|
||||
Positioned.fill(child: ChatWallpaperView(wallpaper: _wallpaper!)),
|
||||
Positioned.fill(
|
||||
child: _buildMessagesArea(),
|
||||
),
|
||||
Positioned.fill(child: _buildMessagesArea()),
|
||||
SearchOverlay(
|
||||
cs: cs,
|
||||
searchAnim: _searchAnim,
|
||||
@@ -3772,7 +3999,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Widget _buildMessagesArea() {
|
||||
final showShimmer = _messages.isEmpty ? _isLoading : _awaitingPosition;
|
||||
final showShimmer = _messages.isEmpty
|
||||
? _isLoading
|
||||
: (_awaitingPosition || _navigatingToTarget);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -3849,160 +4078,184 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
children: [
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: AppCacheExtent.current,
|
||||
builder: (context, cacheExtent, _) => ListView.builder(
|
||||
controller: _scrollController,
|
||||
reverse: true,
|
||||
padding: _messagesListPadding(context),
|
||||
cacheExtent: cacheExtent,
|
||||
itemCount: items.length + 1 + (_isLoadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: _composerHeight,
|
||||
builder: (context, height, _) => SizedBox(
|
||||
height: AppChatChrome.current.value == ChatChromeStyle.color
|
||||
? 0
|
||||
: height,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (index > items.length) {
|
||||
return _buildLoadMoreIndicator();
|
||||
}
|
||||
final item = items[items.length - index];
|
||||
builder: (context, userCacheExtent, _) =>
|
||||
ValueListenableBuilder<double?>(
|
||||
valueListenable: _jumpCacheExtent,
|
||||
builder: (context, jumpExtent, _) {
|
||||
final cacheExtent =
|
||||
jumpExtent != null && jumpExtent < userCacheExtent
|
||||
? jumpExtent
|
||||
: userCacheExtent;
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
reverse: true,
|
||||
padding: _messagesListPadding(context),
|
||||
cacheExtent: cacheExtent,
|
||||
itemCount: items.length + 1 + (_isLoadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: _composerHeight,
|
||||
builder: (context, height, _) => SizedBox(
|
||||
height:
|
||||
AppChatChrome.current.value ==
|
||||
ChatChromeStyle.color
|
||||
? 0
|
||||
: height,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (index > items.length) {
|
||||
return _buildLoadMoreIndicator();
|
||||
}
|
||||
final item = items[items.length - index];
|
||||
|
||||
if (item is _DateSeparatorItem) {
|
||||
return _buildDateSeparatorWidget(
|
||||
context,
|
||||
item.date,
|
||||
key: item.key,
|
||||
);
|
||||
}
|
||||
|
||||
if (item is _UnreadSeparatorItem) {
|
||||
return _buildUnreadSeparatorWidget(context);
|
||||
}
|
||||
|
||||
final msgItem = item as _MessageItem;
|
||||
final message = msgItem.message;
|
||||
final msgIndex = msgItem.index;
|
||||
final isMe = message.senderId == _myId;
|
||||
final prevMessage = msgIndex > 0 ? _messages[msgIndex - 1] : null;
|
||||
final nextMessage = msgIndex < _messages.length - 1
|
||||
? _messages[msgIndex + 1]
|
||||
: null;
|
||||
|
||||
final bubble = MessageBubble(
|
||||
message: message,
|
||||
isMe: isMe,
|
||||
myId: _myId,
|
||||
prevMessage: prevMessage,
|
||||
nextMessage: nextMessage,
|
||||
chatType: chat?.type ?? 'CHAT',
|
||||
overrideStatus: _effectiveStatus(message),
|
||||
otherReadTime: _otherReadTime,
|
||||
reactionsListenable: _reactionNotifierFor(message),
|
||||
uploadProgress: _photoProgressFor(message),
|
||||
onReplyTap: _jumpToMessage,
|
||||
onAvatarTap: _openSenderProfile,
|
||||
onStickerTap: _openStickerPack,
|
||||
);
|
||||
|
||||
final canReport = !isMe && !message.isControl;
|
||||
final reportTypeId = _complaintTypeId(
|
||||
chat?.type ?? widget.chatType,
|
||||
);
|
||||
|
||||
final pressable = _SelectableMessageRow(
|
||||
message: message,
|
||||
isMe: isMe,
|
||||
selectedIds: _selectedIds,
|
||||
selectionAnim: _selectionAnim,
|
||||
isSelectionActive: () => _selectionMode,
|
||||
onToggleSelection: () => _toggleSelection(message),
|
||||
onEnterSelection: () => _enterSelection(message),
|
||||
onDelete: () => _confirmDeleteMessage(message, isMe),
|
||||
onEdit: _canEditMessage(message)
|
||||
? () => _startEditMessage(message)
|
||||
: null,
|
||||
onReply: message.isControl ? null : () => _startReply(message),
|
||||
onForward: message.isControl
|
||||
? null
|
||||
: () => _forwardMessages([message]),
|
||||
onMarkUnread: message.isControl
|
||||
? null
|
||||
: () => _markMessageUnread(message),
|
||||
onPin: _canPinMessage(message)
|
||||
? () => _togglePinMessage(message)
|
||||
: null,
|
||||
isPinned: () => chat?.pinnedMsgId == int.tryParse(message.id),
|
||||
loadReportReasons: canReport
|
||||
? () => _loadReportReasons(reportTypeId)
|
||||
: null,
|
||||
onReport: canReport
|
||||
? (reasonId) =>
|
||||
_reportMessage(message, reportTypeId, reasonId)
|
||||
: null,
|
||||
child: bubble,
|
||||
);
|
||||
|
||||
final isChannel = (chat?.type ?? widget.chatType) == 'CHANNEL';
|
||||
final swipeable = (message.isControl || isChannel)
|
||||
? pressable
|
||||
: _SwipeToReply(
|
||||
isMe: isMe,
|
||||
onReply: () => _startReply(message),
|
||||
child: pressable,
|
||||
);
|
||||
|
||||
final Widget child;
|
||||
if (_deletingIds.contains(message.id)) {
|
||||
child = _DeletingMessageAnimation(
|
||||
key: ValueKey('del_${message.id}'),
|
||||
onComplete: () => _finalizeDelete(message.id),
|
||||
child: IgnorePointer(child: swipeable),
|
||||
);
|
||||
} else if (message.id == _lastSentId) {
|
||||
child = _SentMessageAnimation(
|
||||
key: ValueKey('anim_${message.id}'),
|
||||
onComplete: () {
|
||||
if (mounted) {
|
||||
_lastSentId = null;
|
||||
_bumpMessages();
|
||||
}
|
||||
},
|
||||
child: swipeable,
|
||||
);
|
||||
} else {
|
||||
child = swipeable;
|
||||
}
|
||||
|
||||
final highlightable = ValueListenableBuilder<String?>(
|
||||
valueListenable: _highlightMessageId,
|
||||
builder: (context, hl, c) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
color: hl == message.id
|
||||
? Theme.of(
|
||||
if (item is _DateSeparatorItem) {
|
||||
return _buildDateSeparatorWidget(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
child: c,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
item.date,
|
||||
key: item.key,
|
||||
);
|
||||
}
|
||||
|
||||
final builtItem = RepaintBoundary(
|
||||
key: ValueKey('msg_${message.id}'),
|
||||
child: KeyedSubtree(
|
||||
key: _keyForMessage(message.id),
|
||||
child: highlightable,
|
||||
),
|
||||
);
|
||||
return message.id == _prank.bubbleId
|
||||
? KeyedSubtree(key: _prank.bubbleKey, child: builtItem)
|
||||
: builtItem;
|
||||
},
|
||||
),
|
||||
if (item is _UnreadSeparatorItem) {
|
||||
return _buildUnreadSeparatorWidget(context);
|
||||
}
|
||||
|
||||
final msgItem = item as _MessageItem;
|
||||
final message = msgItem.message;
|
||||
final msgIndex = msgItem.index;
|
||||
final isMe = message.senderId == _myId;
|
||||
final prevMessage = msgIndex > 0
|
||||
? _messages[msgIndex - 1]
|
||||
: null;
|
||||
final nextMessage = msgIndex < _messages.length - 1
|
||||
? _messages[msgIndex + 1]
|
||||
: null;
|
||||
|
||||
final bubble = MessageBubble(
|
||||
message: message,
|
||||
isMe: isMe,
|
||||
myId: _myId,
|
||||
prevMessage: prevMessage,
|
||||
nextMessage: nextMessage,
|
||||
chatType: chat?.type ?? 'CHAT',
|
||||
overrideStatus: _effectiveStatus(message),
|
||||
otherReadTime: _otherReadTime,
|
||||
reactionsListenable: _reactionNotifierFor(message),
|
||||
uploadProgress: _photoProgressFor(message),
|
||||
onReplyTap: _jumpToMessage,
|
||||
onAvatarTap: _openSenderProfile,
|
||||
onStickerTap: _openStickerPack,
|
||||
);
|
||||
|
||||
final canReport = !isMe && !message.isControl;
|
||||
final reportTypeId = _complaintTypeId(
|
||||
chat?.type ?? widget.chatType,
|
||||
);
|
||||
|
||||
final pressable = _SelectableMessageRow(
|
||||
message: message,
|
||||
isMe: isMe,
|
||||
selectedIds: _selectedIds,
|
||||
selectionAnim: _selectionAnim,
|
||||
isSelectionActive: () => _selectionMode,
|
||||
onToggleSelection: () => _toggleSelection(message),
|
||||
onEnterSelection: () => _enterSelection(message),
|
||||
onDelete: () => _confirmDeleteMessage(message, isMe),
|
||||
onEdit: _canEditMessage(message)
|
||||
? () => _startEditMessage(message)
|
||||
: null,
|
||||
onReply: message.isControl
|
||||
? null
|
||||
: () => _startReply(message),
|
||||
onForward: message.isControl
|
||||
? null
|
||||
: () => _forwardMessages([message]),
|
||||
onMarkUnread: message.isControl
|
||||
? null
|
||||
: () => _markMessageUnread(message),
|
||||
onPin: _canPinMessage(message)
|
||||
? () => _togglePinMessage(message)
|
||||
: null,
|
||||
isPinned: () =>
|
||||
chat?.pinnedMsgId == int.tryParse(message.id),
|
||||
loadReportReasons: canReport
|
||||
? () => _loadReportReasons(reportTypeId)
|
||||
: null,
|
||||
onReport: canReport
|
||||
? (reasonId) => _reportMessage(
|
||||
message,
|
||||
reportTypeId,
|
||||
reasonId,
|
||||
)
|
||||
: null,
|
||||
child: bubble,
|
||||
);
|
||||
|
||||
final isChannel =
|
||||
(chat?.type ?? widget.chatType) == 'CHANNEL';
|
||||
final swipeable = (message.isControl || isChannel)
|
||||
? pressable
|
||||
: _SwipeToReply(
|
||||
isMe: isMe,
|
||||
onReply: () => _startReply(message),
|
||||
child: pressable,
|
||||
);
|
||||
|
||||
final Widget child;
|
||||
if (_deletingIds.contains(message.id)) {
|
||||
child = _DeletingMessageAnimation(
|
||||
key: ValueKey('del_${message.id}'),
|
||||
onComplete: () => _finalizeDelete(message.id),
|
||||
child: IgnorePointer(child: swipeable),
|
||||
);
|
||||
} else if (message.id == _lastSentId) {
|
||||
child = _SentMessageAnimation(
|
||||
key: ValueKey('anim_${message.id}'),
|
||||
onComplete: () {
|
||||
if (mounted) {
|
||||
_lastSentId = null;
|
||||
_bumpMessages();
|
||||
}
|
||||
},
|
||||
child: swipeable,
|
||||
);
|
||||
} else {
|
||||
child = swipeable;
|
||||
}
|
||||
|
||||
final highlightable = ValueListenableBuilder<String?>(
|
||||
valueListenable: _highlightMessageId,
|
||||
builder: (context, hl, c) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
color: hl == message.id
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
child: c,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
final builtItem = RepaintBoundary(
|
||||
key: ValueKey('msg_${message.id}'),
|
||||
child: KeyedSubtree(
|
||||
key: _keyForMessage(message.id),
|
||||
child: highlightable,
|
||||
),
|
||||
);
|
||||
return message.id == _prank.bubbleId
|
||||
? KeyedSubtree(
|
||||
key: _prank.bubbleKey,
|
||||
child: builtItem,
|
||||
)
|
||||
: builtItem;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: _floatingDateTop(context),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -497,6 +497,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedMembersCount": "{count, plural, =1{1 member} other{{count} members}}",
|
||||
"@sharedMembersCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedLoadMore": "Show more",
|
||||
"sharedGoToMessage": "Go to message",
|
||||
"sharedDownload": "Download",
|
||||
"sharedCopyLink": "Copy link",
|
||||
"sharedLinkCopied": "Link copied",
|
||||
"chatInfoActionLeave": "Leave",
|
||||
"chatInfoBio": "About",
|
||||
"chatInfoInviteLink": "Invite link",
|
||||
|
||||
@@ -2462,6 +2462,42 @@ abstract class AppLocalizations {
|
||||
/// **'{online} of {total} online'**
|
||||
String chatInfoOnlineOfTotal(String online, String total);
|
||||
|
||||
/// No description provided for @sharedMembersCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 member} other{{count} members}}'**
|
||||
String sharedMembersCount(int count);
|
||||
|
||||
/// No description provided for @sharedLoadMore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show more'**
|
||||
String get sharedLoadMore;
|
||||
|
||||
/// No description provided for @sharedGoToMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Go to message'**
|
||||
String get sharedGoToMessage;
|
||||
|
||||
/// No description provided for @sharedDownload.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download'**
|
||||
String get sharedDownload;
|
||||
|
||||
/// No description provided for @sharedCopyLink.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copy link'**
|
||||
String get sharedCopyLink;
|
||||
|
||||
/// No description provided for @sharedLinkCopied.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Link copied'**
|
||||
String get sharedLinkCopied;
|
||||
|
||||
/// No description provided for @chatInfoActionLeave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -1244,6 +1244,32 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$online of $total online';
|
||||
}
|
||||
|
||||
@override
|
||||
String sharedMembersCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count members',
|
||||
one: '1 member',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get sharedLoadMore => 'Show more';
|
||||
|
||||
@override
|
||||
String get sharedGoToMessage => 'Go to message';
|
||||
|
||||
@override
|
||||
String get sharedDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get sharedCopyLink => 'Copy link';
|
||||
|
||||
@override
|
||||
String get sharedLinkCopied => 'Link copied';
|
||||
|
||||
@override
|
||||
String get chatInfoActionLeave => 'Leave';
|
||||
|
||||
|
||||
@@ -1249,6 +1249,34 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '$online из $total в сети';
|
||||
}
|
||||
|
||||
@override
|
||||
String sharedMembersCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count участника',
|
||||
many: '$count участников',
|
||||
few: '$count участника',
|
||||
one: '1 участник',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get sharedLoadMore => 'Показать ещё';
|
||||
|
||||
@override
|
||||
String get sharedGoToMessage => 'Перейти к сообщению';
|
||||
|
||||
@override
|
||||
String get sharedDownload => 'Скачать';
|
||||
|
||||
@override
|
||||
String get sharedCopyLink => 'Копировать ссылку';
|
||||
|
||||
@override
|
||||
String get sharedLinkCopied => 'Ссылка скопирована';
|
||||
|
||||
@override
|
||||
String get chatInfoActionLeave => 'Покинуть';
|
||||
|
||||
|
||||
@@ -417,6 +417,12 @@
|
||||
"chatInfoEmptyVoice": "Нет голосовых",
|
||||
"chatInfoEmptyLinks": "Нет ссылок",
|
||||
"chatInfoOnlineOfTotal": "{online} из {total} в сети",
|
||||
"sharedMembersCount": "{count, plural, =1{1 участник} few{{count} участника} many{{count} участников} other{{count} участника}}",
|
||||
"sharedLoadMore": "Показать ещё",
|
||||
"sharedGoToMessage": "Перейти к сообщению",
|
||||
"sharedDownload": "Скачать",
|
||||
"sharedCopyLink": "Копировать ссылку",
|
||||
"sharedLinkCopied": "Ссылка скопирована",
|
||||
"chatInfoActionLeave": "Покинуть",
|
||||
"chatInfoBio": "О себе",
|
||||
"chatInfoInviteLink": "Ссылка-приглашение",
|
||||
|
||||
@@ -49,6 +49,7 @@ import 'backend/modules/outbox.dart';
|
||||
import 'backend/modules/polls.dart';
|
||||
import 'backend/modules/stickers.dart';
|
||||
import 'backend/modules/self_check.dart';
|
||||
import 'backend/modules/shared_content.dart';
|
||||
import 'backend/modules/webapp.dart';
|
||||
import 'backend/modules/digital_id.dart';
|
||||
import 'core/calls/call_bridge.dart';
|
||||
@@ -73,6 +74,7 @@ import 'frontend/widgets/theme_reveal.dart';
|
||||
final api = Api();
|
||||
final accountModule = AccountModule(api);
|
||||
final messagesModule = MessagesModule(api);
|
||||
final sharedContentModule = SharedContentModule(api);
|
||||
final pollsModule = PollsModule(api);
|
||||
final stickersModule = StickersModule(api);
|
||||
final webAppModule = WebAppModule(api);
|
||||
|
||||
Reference in New Issue
Block a user