diff --git a/lib/backend/modules/shared_content.dart b/lib/backend/modules/shared_content.dart new file mode 100644 index 0000000..d33c7a6 --- /dev/null +++ b/lib/backend/modules/shared_content.dart @@ -0,0 +1,192 @@ +import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/logger.dart'; +import '../../models/attachment.dart'; +import '../api.dart'; + +const Map _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 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 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 map) { + final participants = map['participants']; + final ids = []; + 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 fetchMedia({ + required int chatId, + required String anchorMessageId, + required List 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() + .toSet(); + + final out = []; + for (final m in messages) { + if (m is! Map) continue; + final map = Map.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.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> 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 = []; + for (final c in chats) { + if (c is Map) { + out.add(CommonChatEntry.fromMap(Map.from(c))); + } + } + return out; + } catch (e) { + logger.w('SharedContent.fetchCommonChats failed: $e'); + return const []; + } + } +} diff --git a/lib/core/utils/media_saver.dart b/lib/core/utils/media_saver.dart index 39968a2..a29ff0b 100644 --- a/lib/core/utils/media_saver.dart +++ b/lib/core/utils/media_saver.dart @@ -51,6 +51,52 @@ Future saveImageFromUrl(String url) async { } } +enum SaveMediaKind { image, video, file } + +Future saveMediaFile({ + required String cacheName, + required Future 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 _targetDirectory() async { try { final downloads = await getDownloadsDirectory(); diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 94eaea4..a4fff33 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -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 { List<_MemberInfo> _members = []; int _onlineCount = 0; + int _mediaChatId = 0; + String? _anchorMsgId; + @override void initState() { super.initState(); @@ -140,6 +148,23 @@ class _ChatInfoScreenState extends State { 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 { 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), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 0601305..3bba577 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 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 final ValueNotifier _replyTo = ValueNotifier(null); final ValueNotifier _highlightMessageId = ValueNotifier(null); Timer? _highlightTimer; + final ValueNotifier _jumpCacheExtent = ValueNotifier(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 _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 _loadUntilUnreadReady() async { @@ -754,7 +804,12 @@ class _ChatScreenState extends State 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 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 _replyTo.dispose(); _highlightTimer?.cancel(); _highlightMessageId.dispose(); + _goToMessageSettleTimer?.cancel(); + _jumpCacheExtent.dispose(); _messageKeys.clear(); super.dispose(); } @@ -2158,17 +2216,29 @@ class _ChatScreenState extends State 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 _search.reset(); } + Future _navigateToInitialMessage() async { + final id = widget.initialMessageId; + if (id == null) { + _finishTargetNavigation(); + return; + } + await _runGoToMessage(id, widget.initialMessageTime ?? 0); + } + + Future _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 _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 _openSearchResult(MessageSearchResult result) async { _closeSearch(); await WidgetsBinding.instance.endOfFrame; @@ -3243,7 +3470,9 @@ class _ChatScreenState extends State 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 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 } 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 children: [ ValueListenableBuilder( 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( - 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( + 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( + 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( - 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( + 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), diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart new file mode 100644 index 0000000..aee381d --- /dev/null +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -0,0 +1,1174 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:komet/main.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:ogg_opus_player/ogg_opus_player.dart'; + +import '../../../backend/modules/messages.dart' show ContactCache; +import '../../../backend/modules/shared_content.dart'; +import '../../../core/cache/info_cache.dart'; +import '../../../core/utils/download_progress.dart'; +import '../../../core/utils/file_download.dart'; +import '../../../core/utils/format.dart'; +import '../../../core/utils/link_opener.dart'; +import '../../../core/utils/logger.dart'; +import '../../../core/utils/media_cache.dart'; +import '../../../core/utils/media_saver.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../models/attachment.dart'; +import '../../screens/chats/chat_screen.dart'; +import '../custom_notification.dart'; +import '../komet_avatar.dart'; +import '../photo_viewer.dart'; +import '../swipe_route.dart'; +import '../video_player_screen.dart'; + +enum SharedContentKind { media, files, voice, links } + +extension on SharedContentKind { + List get attachTypes { + switch (this) { + case SharedContentKind.media: + return const ['PHOTO', 'VIDEO']; + case SharedContentKind.files: + return const ['FILE']; + case SharedContentKind.voice: + return const ['AUDIO']; + case SharedContentKind.links: + return const ['SHARE']; + } + } +} + +const List _ruMonthsFull = [ + 'Январь', + 'Февраль', + 'Март', + 'Апрель', + 'Май', + 'Июнь', + 'Июль', + 'Август', + 'Сентябрь', + 'Октябрь', + 'Ноябрь', + 'Декабрь', +]; + +const List _enMonthsFull = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +String _monthHeader(String locale, DateTime date) { + final months = locale.startsWith('ru') ? _ruMonthsFull : _enMonthsFull; + final now = DateTime.now(); + final name = months[date.month - 1]; + final label = date.year == now.year ? name : '$name ${date.year}'; + return label.toUpperCase(); +} + +Widget _emptyState(ColorScheme cs, String label, IconData icon) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: cs.onSurfaceVariant.withValues(alpha: 0.35), size: 48), + const SizedBox(height: 12), + Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)), + ], + ), + ); +} + +Widget _loadingState(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 56), + child: Center( + child: SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2.5, color: cs.primary), + ), + ), + ); +} + +Widget _sectionHeader(ColorScheme cs, String label) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 12, 4, 8), + child: Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.6, + ), + ), + ); +} + +List<({DateTime month, List items})> _groupByMonth( + List items, +) { + final groups = <({DateTime month, List items})>[]; + DateTime? current; + for (final item in items) { + final dt = DateTime.fromMillisecondsSinceEpoch(item.time); + final monthStart = DateTime(dt.year, dt.month); + if (current == null || current != monthStart) { + current = monthStart; + groups.add((month: monthStart, items: [item])); + } else { + groups.last.items.add(item); + } + } + return groups; +} + +class _MenuAction { + final IconData icon; + final String label; + final Future Function() onTap; + const _MenuAction(this.icon, this.label, this.onTap); +} + +Future _showItemMenu(BuildContext context, List<_MenuAction> actions) { + final cs = Theme.of(context).colorScheme; + return showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 10), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 8), + for (final action in actions) + ListTile( + leading: Icon(action.icon, color: cs.onSurface), + title: Text( + action.label, + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + onTap: () { + Navigator.pop(sheetContext); + action.onTap(); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); +} + +Widget _moreButton(ColorScheme cs, VoidCallback onTap, {bool overlay = false}) { + if (overlay) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Symbols.more_horiz, color: Colors.white, size: 18), + ), + ); + } + return IconButton( + onPressed: onTap, + icon: Icon(Symbols.more_vert, color: cs.onSurfaceVariant, size: 22), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + ); +} + +void _notifySave(BuildContext context, MediaSaveResult result) { + if (!context.mounted) return; + if (result.ok) { + showCustomNotification( + context, + result.toGallery ? 'Сохранено в галерею' : 'Файл сохранён', + ); + } else { + showCustomNotification(context, 'Не удалось сохранить: ${result.error ?? ''}'); + } +} + +Future _downloadAttachment( + BuildContext context, + SharedMediaItem item, +) async { + final att = item.attachment; + final now = DateTime.now().millisecondsSinceEpoch; + + if (att is PhotoAttachment) { + final url = att.baseUrl ?? ''; + if (url.isEmpty) return; + final result = await saveMediaFile( + cacheName: 'photo_${att.photoId ?? url.hashCode}.jpg', + resolveUrl: () async => url, + saveName: 'IMG_$now.jpg', + kind: SaveMediaKind.image, + ); + if (context.mounted) _notifySave(context, result); + return; + } + + if (att is VideoAttachment) { + final result = await saveMediaFile( + cacheName: 'video_${att.videoId ?? item.messageId}.mp4', + resolveUrl: () async { + final sources = await messagesModule.getVideoSources( + messageId: item.messageId, + chatId: item.chatId, + token: att.videoToken ?? '', + videoId: att.videoId ?? 0, + ); + return sources.values.isEmpty ? null : sources.values.first; + }, + saveName: 'VID_$now.mp4', + kind: SaveMediaKind.video, + ); + if (context.mounted) _notifySave(context, result); + return; + } + + if (att is FileAttachment) { + final fileId = att.fileId; + if (fileId == null) return; + final name = att.name ?? 'file_$now'; + final result = await saveMediaFile( + cacheName: '${fileId}_$name', + resolveUrl: () => messagesModule.getFileUrl( + messageId: item.messageId, + chatId: item.chatId, + fileId: fileId, + ), + saveName: name, + kind: SaveMediaKind.file, + ); + if (context.mounted) _notifySave(context, result); + return; + } + + if (att is AudioAttachment) { + final url = att.fileUrl ?? att.baseUrl ?? ''; + if (url.isEmpty) return; + final result = await saveMediaFile( + cacheName: '${att.audioId ?? item.messageId}.ogg', + resolveUrl: () async => url, + saveName: 'AUD_$now.ogg', + kind: SaveMediaKind.file, + ); + if (context.mounted) _notifySave(context, result); + } +} + +class CommonChatsTab extends StatefulWidget { + final int userId; + final String emptyLabel; + + const CommonChatsTab({ + super.key, + required this.userId, + required this.emptyLabel, + }); + + @override + State createState() => _CommonChatsTabState(); +} + +class _CommonChatsTabState extends State { + bool _loading = true; + List _chats = const []; + Map _onlineByChat = const {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final chats = await sharedContentModule.fetchCommonChats(widget.userId); + + final allIds = {}; + for (final c in chats) { + allIds.addAll(c.participantIds); + } + + final onlineByChat = {}; + if (allIds.isNotEmpty) { + try { + final presence = await PresenceFetch.getMany(allIds.toList()); + for (final c in chats) { + var online = 0; + for (final id in c.participantIds) { + if ((presence[id]?['status'] as int?) == 1) online++; + } + onlineByChat[c.id] = online; + } + } catch (e) { + logger.w('CommonChatsTab presence failed: $e'); + } + } + + if (!mounted) return; + setState(() { + _chats = chats; + _onlineByChat = onlineByChat; + _loading = false; + }); + } + + void _openChat(CommonChatEntry chat) { + final type = chat.type == 'CHANNEL' ? 'CHANNEL' : 'CHAT'; + pushSwipeable( + context, + (_) => ChatScreen( + chatId: chat.id, + name: chat.title, + imageUrl: chat.iconUrl ?? '', + chatType: type, + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (_loading) return _loadingState(cs); + if (_chats.isEmpty) { + return _emptyState(cs, widget.emptyLabel, Icons.group); + } + + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + for (int i = 0; i < _chats.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: 68, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + _tile(cs, _chats[i]), + ], + ], + ), + ); + } + + Widget _tile(ColorScheme cs, CommonChatEntry chat) { + final l10n = AppLocalizations.of(context)!; + final online = _onlineByChat[chat.id] ?? 0; + final total = chat.participantsCount; + final subtitle = online > 0 + ? l10n.chatInfoOnlineOfTotal('$online', '$total') + : l10n.sharedMembersCount(total); + + return InkWell( + onTap: () => _openChat(chat), + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + KometAvatar( + name: chat.title, + imageUrl: chat.iconUrl, + size: 46, + fontSize: 18, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + chat.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class SharedMediaTab extends StatefulWidget { + final int chatId; + final String anchorMessageId; + final int myId; + final SharedContentKind kind; + final String emptyLabel; + final IconData emptyIcon; + final void Function(String messageId, int time) onGoToMessage; + + const SharedMediaTab({ + super.key, + required this.chatId, + required this.anchorMessageId, + required this.myId, + required this.kind, + required this.emptyLabel, + required this.emptyIcon, + required this.onGoToMessage, + }); + + @override + State createState() => _SharedMediaTabState(); +} + +class _SharedMediaTabState extends State { + static const int _pageSize = 60; + + bool _loading = true; + bool _loadingMore = false; + bool _canLoadMore = false; + final List _items = []; + final Set _seen = {}; + + @override + void initState() { + super.initState(); + _load(widget.anchorMessageId, initial: true); + } + + Future _load(String anchor, {required bool initial}) async { + final page = await sharedContentModule.fetchMedia( + chatId: widget.chatId, + anchorMessageId: anchor, + attachTypes: widget.kind.attachTypes, + forward: initial ? _pageSize : 0, + backward: _pageSize, + ); + if (!mounted) return; + + final pageMessageIds = page.items.map((e) => e.messageId).toSet(); + var added = 0; + for (final item in page.items) { + if (_seen.add(item.dedupKey)) { + _items.add(item); + added++; + } + } + _items.sort((a, b) => b.time.compareTo(a.time)); + + setState(() { + _canLoadMore = added > 0 && pageMessageIds.length >= _pageSize; + _loading = false; + _loadingMore = false; + }); + } + + Future _loadMore() async { + if (_loadingMore || _items.isEmpty) return; + setState(() => _loadingMore = true); + await _load(_items.last.messageId, initial: false); + } + + bool get _hasMore => _canLoadMore; + + String _resolveName(int senderId) { + final l10n = AppLocalizations.of(context)!; + if (senderId == widget.myId) return l10n.callParticipantYou; + return ContactCache.get(senderId) ?? '#$senderId'; + } + + void _goTo(SharedMediaItem item) => + widget.onGoToMessage(item.messageId, item.time); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (_loading) return _loadingState(cs); + if (_items.isEmpty) { + return _emptyState(cs, widget.emptyLabel, widget.emptyIcon); + } + + final l10n = AppLocalizations.of(context)!; + final locale = l10n.localeName; + final groups = _groupByMonth(_items); + final children = []; + + for (final group in groups) { + children.add(_sectionHeader(cs, _monthHeader(locale, group.month))); + switch (widget.kind) { + case SharedContentKind.media: + children.add(_mediaGrid(cs, group.items)); + case SharedContentKind.files: + children.addAll( + group.items.map((i) => _FileRow(item: i, onGoTo: () => _goTo(i))), + ); + case SharedContentKind.voice: + children.addAll( + group.items.map( + (i) => _ProfileVoiceTile( + item: i, + senderName: _resolveName(i.senderId), + onGoTo: () => _goTo(i), + ), + ), + ); + case SharedContentKind.links: + children.addAll( + group.items.map((i) => _LinkRow(item: i, onGoTo: () => _goTo(i))), + ); + } + } + + if (_hasMore) { + children.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: _loadingMore + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: cs.primary, + ), + ) + : TextButton( + onPressed: _loadMore, + child: Text(l10n.sharedLoadMore), + ), + ), + ), + ); + } + + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: children); + } + + Widget _mediaGrid(ColorScheme cs, List items) { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 3, + crossAxisSpacing: 3, + ), + itemCount: items.length, + itemBuilder: (context, index) => + _MediaTile(item: items[index], onGoTo: () => _goTo(items[index])), + ); + } +} + +class _MediaTile extends StatelessWidget { + final SharedMediaItem item; + final VoidCallback onGoTo; + + const _MediaTile({required this.item, required this.onGoTo}); + + void _menu(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + _showItemMenu(context, [ + _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { + onGoTo(); + }), + _MenuAction(Symbols.download, l10n.sharedDownload, () async { + await _downloadAttachment(context, item); + }), + ]); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final att = item.attachment; + final video = att is VideoAttachment ? att : null; + final duration = video?.duration ?? 0; + final thumb = att.baseUrl?.isNotEmpty == true ? att.baseUrl : att.previewData; + + return GestureDetector( + onTap: () => _open(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + fit: StackFit.expand, + children: [ + Container(color: cs.surfaceContainerHighest), + if (thumb != null && thumb.isNotEmpty) + CachedNetworkImage( + imageUrl: thumb, + fit: BoxFit.cover, + memCacheWidth: 300, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => Icon( + video != null ? Symbols.movie : Symbols.image, + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + ), + ), + if (video != null) ...[ + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.center, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black54], + ), + ), + ), + const Center( + child: Icon(Symbols.play_arrow, color: Colors.white, size: 34), + ), + if (duration > 0) + Positioned( + left: 6, + bottom: 6, + child: Text( + formatSecondsMmSs((duration / 1000).round()), + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + Positioned( + top: 4, + right: 4, + child: _moreButton(cs, () => _menu(context), overlay: true), + ), + ], + ), + ), + ); + } + + Future _open(BuildContext context) async { + final att = item.attachment; + if (att is VideoAttachment) { + final sources = await messagesModule.getVideoSources( + messageId: item.messageId, + chatId: item.chatId, + token: att.videoToken ?? '', + videoId: att.videoId ?? 0, + ); + if (!context.mounted) return; + if (sources.isEmpty) { + showCustomNotification(context, 'Не удалось загрузить видео'); + return; + } + pushSwipeable(context, (_) => VideoPlayerScreen(sources: sources)); + return; + } + final url = att.baseUrl ?? att.previewData ?? ''; + if (url.isEmpty) return; + pushSwipeable(context, (_) => PhotoViewerScreen(baseUrl: url)); + } +} + +class _FileRow extends StatelessWidget { + final SharedMediaItem item; + final VoidCallback onGoTo; + + const _FileRow({required this.item, required this.onGoTo}); + + void _menu(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + _showItemMenu(context, [ + _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { + onGoTo(); + }), + _MenuAction(Symbols.download, l10n.sharedDownload, () async { + await _downloadAttachment(context, item); + }), + ]); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final att = item.attachment as FileAttachment; + final fullName = att.name ?? 'file'; + final dot = fullName.lastIndexOf('.'); + final ext = dot > 0 && dot < fullName.length - 1 + ? fullName.substring(dot + 1).toUpperCase() + : ''; + final displayName = dot > 0 ? fullName.substring(0, dot) : fullName; + final size = att.size ?? 0; + final cacheName = '${att.fileId}_$fullName'; + + return InkWell( + onTap: () => _open(context, cacheName), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + _badge(cs, ext, cacheName), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + ext.isEmpty ? formatBytes(size) : '$ext • ${formatBytes(size)}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + _moreButton(cs, () => _menu(context)), + ], + ), + ), + ); + } + + Widget _badge(ColorScheme cs, String ext, String cacheName) { + return SizedBox( + width: 46, + height: 46, + child: ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: progress != null + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.2, + value: progress > 0 ? progress : null, + color: cs.primary, + ), + ) + : Stack( + alignment: Alignment.center, + children: [ + Icon( + Symbols.download, + color: cs.onSurfaceVariant.withValues(alpha: 0.5), + size: 26, + ), + if (ext.isNotEmpty) + Positioned( + bottom: 4, + child: Text( + ext, + style: TextStyle( + color: cs.onSurface, + fontSize: 8, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + + Future _open(BuildContext context, String cacheName) async { + final att = item.attachment as FileAttachment; + final fileId = att.fileId; + if (fileId == null) return; + if (MediaDownloadProgress.notifier(cacheName).value != null) return; + + MediaDownloadProgress.set(cacheName, 0); + final result = await openCachedFile( + cacheName, + () => messagesModule.getFileUrl( + messageId: item.messageId, + chatId: item.chatId, + fileId: fileId, + ), + onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + ); + MediaDownloadProgress.set(cacheName, null); + + if (!context.mounted) return; + if (!result.ok) { + showCustomNotification(context, 'Не удалось открыть файл'); + } + } +} + +class _LinkRow extends StatelessWidget { + final SharedMediaItem item; + final VoidCallback onGoTo; + + const _LinkRow({required this.item, required this.onGoTo}); + + void _menu(BuildContext context, String url) { + final l10n = AppLocalizations.of(context)!; + _showItemMenu(context, [ + _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { + onGoTo(); + }), + if (url.isNotEmpty) + _MenuAction(Symbols.content_copy, l10n.sharedCopyLink, () async { + await Clipboard.setData(ClipboardData(text: url)); + if (context.mounted) { + showCustomNotification(context, l10n.sharedLinkCopied); + } + }), + ]); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final att = item.attachment as ShareAttachment; + final url = att.url ?? ''; + final host = + att.host ?? (url.isNotEmpty ? Uri.tryParse(url)?.host ?? '' : ''); + final title = att.title ?? url; + final image = att.image; + final thumb = image?.baseUrl?.isNotEmpty == true + ? image!.baseUrl + : image?.previewData; + + return InkWell( + onTap: url.isEmpty ? null : () => openExternalUrl(context, url), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 46, + height: 46, + color: cs.surfaceContainerHighest, + child: (thumb != null && thumb.isNotEmpty) + ? CachedNetworkImage( + imageUrl: thumb, + fit: BoxFit.cover, + memCacheWidth: 120, + errorWidget: (_, _, _) => Icon( + Symbols.link, + color: cs.onSurfaceVariant.withValues(alpha: 0.5), + ), + ) + : Icon( + Symbols.link, + color: cs.onSurfaceVariant.withValues(alpha: 0.5), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (host.isNotEmpty) + Text( + host, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 11, + ), + ), + if (title.isNotEmpty) + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (att.description != null && + att.description!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + att.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.25, + ), + ), + ], + if (url.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: cs.primary, fontSize: 13), + ), + ], + ], + ), + ), + _moreButton(cs, () => _menu(context, url)), + ], + ), + ), + ); + } +} + +class _ProfileVoiceTile extends StatefulWidget { + final SharedMediaItem item; + final String senderName; + final VoidCallback onGoTo; + + const _ProfileVoiceTile({ + required this.item, + required this.senderName, + required this.onGoTo, + }); + + @override + State<_ProfileVoiceTile> createState() => _ProfileVoiceTileState(); +} + +class _ProfileVoiceTileState extends State<_ProfileVoiceTile> { + OggOpusPlayer? _player; + bool _isPlaying = false; + bool _loadingAudio = false; + Timer? _ticker; + final ValueNotifier _progress = ValueNotifier(0.0); + + AudioAttachment get _audio => widget.item.attachment as AudioAttachment; + int get _durationSec => ((_audio.duration ?? 0) / 1000).round(); + + @override + void dispose() { + _ticker?.cancel(); + _player?.state.removeListener(_onPlayerState); + _player?.dispose(); + _progress.dispose(); + super.dispose(); + } + + Future _togglePlay() async { + if (_loadingAudio) return; + + if (_player != null) { + if (_isPlaying) { + _player!.pause(); + } else { + final dur = _audio.duration ?? 0; + if (dur > 0 && _player!.currentPosition * 1000 >= dur - 50) { + _progress.value = 0; + } + _player!.play(); + } + return; + } + + final url = _audio.fileUrl ?? _audio.baseUrl ?? ''; + if (url.isEmpty) return; + + setState(() => _loadingAudio = true); + try { + final name = '${_audio.audioId ?? widget.item.messageId}.ogg'; + final file = await MediaCache.getOrDownload(name, url); + if (!mounted) return; + if (file == null) { + showCustomNotification(context, 'Не удалось загрузить аудио'); + return; + } + final player = OggOpusPlayer(file.path); + _player = player; + player.state.addListener(_onPlayerState); + _ticker = Timer.periodic( + const Duration(milliseconds: 60), + (_) => _onTick(), + ); + player.play(); + } catch (e) { + logger.w('ProfileVoiceTile._togglePlay: $e'); + if (mounted) showCustomNotification(context, 'Ошибка воспроизведения'); + } finally { + if (mounted) setState(() => _loadingAudio = false); + } + } + + void _onTick() { + final player = _player; + final dur = _audio.duration ?? 0; + if (player == null || dur <= 0) return; + _progress.value = (player.currentPosition * 1000 / dur).clamp(0.0, 1.0); + } + + void _onPlayerState() { + if (!mounted) return; + final state = _player?.state.value; + final playing = state == PlayerState.playing; + if (playing != _isPlaying) setState(() => _isPlaying = playing); + if (state == PlayerState.ended) _progress.value = 1.0; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final date = DateTime.fromMillisecondsSinceEpoch(widget.item.time); + final subtitle = + '${formatSecondsMmSs(_durationSec)} • ${formatDateTimeWords(date)}'; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + GestureDetector( + onTap: _togglePlay, + child: Container( + width: 46, + height: 46, + decoration: BoxDecoration(color: cs.primary, shape: BoxShape.circle), + child: _loadingAudio + ? const Padding( + padding: EdgeInsets.all(13), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : ValueListenableBuilder( + valueListenable: _progress, + builder: (context, progress, child) => Stack( + alignment: Alignment.center, + children: [ + if (progress > 0 && progress < 1) + SizedBox( + width: 46, + height: 46, + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress, + color: cs.onPrimary.withValues(alpha: 0.5), + backgroundColor: Colors.transparent, + ), + ), + child!, + ], + ), + child: Icon( + _isPlaying ? Symbols.pause : Symbols.play_arrow, + color: cs.onPrimary, + size: 24, + fill: 1, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.senderName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + _moreButton(cs, () => _menu(context)), + ], + ), + ); + } + + void _menu(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + _showItemMenu(context, [ + _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { + widget.onGoTo(); + }), + _MenuAction(Symbols.download, l10n.sharedDownload, () async { + await _downloadAttachment(context, widget.item); + }), + ]); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a52c456..8a2d585 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7c1c966..8589873 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a9e1de7..62648e4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 47bc2b0..b28f5c2 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Покинуть'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e7e04db..ac73c2e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -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": "Ссылка-приглашение", diff --git a/lib/main.dart b/lib/main.dart index 544baf7..3d34f96 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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);