From cf8e50c6f772a7d9e37697946ae54ce1358ed0d5 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Thu, 16 Jul 2026 00:22:02 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D0=B8,=20=D0=BD=D0=B5=D0=BC=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=81=D1=8B=D1=80=D1=8B=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 9 + lib/backend/modules/comments.dart | 307 ++++++++++++++++ lib/core/protocol/opcode_map.dart | 6 + .../chats/chat/view/composer_input.dart | 90 +++-- lib/frontend/screens/chats/chat_screen.dart | 347 +++++++++++++++--- .../widgets/message_actions_overlay.dart | 17 +- lib/frontend/widgets/message_bubble.dart | 109 +++++- lib/main.dart | 3 + 8 files changed, 772 insertions(+), 116 deletions(-) create mode 100644 lib/backend/modules/comments.dart diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index b2da53c..918c224 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -639,6 +639,15 @@ class ChatsModule { final msg = payload['message']; if (msg is! Map) return; + final msgLink = msg['link']; + final linkPostId = (msgLink is Map) ? msgLink['postId'] : null; + final payloadPostId = payload['postId']; + final isCommentPush = + payloadPostId is String || + (linkPostId is String) || + (msg['postId'] is String); + if (isCommentPush) return; + final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return; diff --git a/lib/backend/modules/comments.dart b/lib/backend/modules/comments.dart new file mode 100644 index 0000000..dc9d94e --- /dev/null +++ b/lib/backend/modules/comments.dart @@ -0,0 +1,307 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../api.dart'; +import 'messages.dart' show CachedMessage; + +class CommentsInfo { + final String postId; + final int? totalCount; + final int? updatedAt; + const CommentsInfo({ + required this.postId, + this.totalCount, + this.updatedAt, + }); + + factory CommentsInfo.fromPayload(String postId, Map payload) { + final raw = payload['totalCount']; + int? count; + if (raw is int) { + count = raw; + } else if (raw is String) { + count = int.tryParse(raw); + } + return CommentsInfo( + postId: postId, + totalCount: count, + updatedAt: DateTime.now().millisecondsSinceEpoch, + ); + } +} + +class CommentAddedEvent { + final int chatId; + final String postId; + final CachedMessage comment; + const CommentAddedEvent(this.chatId, this.postId, this.comment); +} + +class CommentsModule { + final Api _api; + + CommentsModule(this._api); + + final ValueNotifier revision = ValueNotifier(0); + + final _infoController = + StreamController>.broadcast(); + Stream> get infoStream => _infoController.stream; + + final _commentController = StreamController.broadcast(); + Stream get commentStream => _commentController.stream; + + Map _info = {}; + Map get infoSnapshot => Map.unmodifiable(_info); + + CommentsInfo? infoFor(String postId) => _info[postId]; + + int _accountId = 0; + + void dispose() { + _pushSub?.cancel(); + _pushSub = null; + _infoController.close(); + _commentController.close(); + revision.dispose(); + } + + void attachPushHandlers(Api api) { + _pushSub?.cancel(); + _pushSub = api.pushStream.listen(_handlePush); + } + + StreamSubscription? _pushSub; + + void _handlePush(Packet packet) { + switch (packet.opcode) { + case Opcode.commentsInfo: + final payload = packet.payload; + if (payload is! Map) return; + final updates = payload['commentsInfoUpdates']; + if (updates is List) handleInfoUpdate(updates); + case Opcode.notifMessage: + _handleCommentPush(packet); + } + } + + void _handleCommentPush(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final msg = payload['message']; + if (msg is! Map) return; + + final link = msg['link']; + final postId = + (payload['postId'] ?? (link is Map ? link['postId'] : null) ?? + msg['postId']) + ?.toString(); + if (postId == null || postId.isEmpty) return; + + final comment = _parseComment( + msg.cast(), + _accountId, + chatId, + postId, + ); + if (comment == null) return; + _commentController.add(CommentAddedEvent(chatId, postId, comment)); + } + + void handleInfoUpdate(List updates) { + if (updates.isEmpty) return; + Map? next; + for (final raw in updates) { + if (raw is! Map) continue; + final postId = raw['postId']?.toString(); + final commentsInfo = raw['commentsInfo']; + if (postId == null || commentsInfo is! Map) continue; + final updated = CommentsInfo.fromPayload( + postId, + Map.from(commentsInfo.cast()), + ); + next ??= Map.from(_info); + next[postId] = updated; + } + if (next == null) return; + _info = next; + revision.value = revision.value + 1; + _infoController.add(Map.unmodifiable(_info)); + } + + Future> fetchInfo({ + required int accountId, + required int chatId, + required List postIds, + }) async { + _accountId = accountId; + if (postIds.isEmpty) return const {}; + final response = await _api.sendRequest(Opcode.commentsInfo, { + 'chatId': chatId, + 'postIds': postIds.map((id) => int.tryParse(id) ?? id).toList(), + }); + if (!response.isOk) return const {}; + final payload = response.payload; + if (payload is! Map) return const {}; + final updates = payload['commentsInfoUpdates']; + if (updates is! List) return const {}; + handleInfoUpdate(updates); + final byPost = {}; + for (final raw in updates.whereType()) { + final postId = raw['postId']?.toString(); + final commentsInfo = raw['commentsInfo']; + if (postId == null || commentsInfo is! Map) continue; + byPost[postId] = CommentsInfo.fromPayload( + postId, + Map.from(commentsInfo.cast()), + ); + } + return byPost; + } + + Future> fetchHistory( + int accountId, + int chatId, + String postId, { + required int fromTime, + int forward = 30, + int backward = 15, + }) async { + _accountId = accountId; + final payload = { + 'chatId': chatId, + 'postId': int.tryParse(postId) ?? postId, + 'from': fromTime, + 'forward': forward, + 'backward': backward, + 'getMessages': true, + }; + + final response = await _api.sendRequest(Opcode.chatHistory, payload); + if (!response.isOk) return const []; + final data = response.payload; + if (data is! Map) return const []; + + final messagesData = data['messages']; + if (messagesData is! List) return const []; + + final results = []; + for (var i = 0; i < messagesData.length; i++) { + final m = messagesData[i]; + if (m is! Map) continue; + final parsed = _parseComment( + m.cast(), + accountId, + chatId, + postId, + ); + if (parsed != null) results.add(parsed); + if (i > 0 && i % 20 == 0) await Future.delayed(Duration.zero); + } + + return results; + } + + Future sendComment( + int accountId, + int chatId, + String postId, + String text, { + bool notify = true, + int? replyToMessageId, + List> elements = const [], + }) async { + _accountId = accountId; + final Object postIdField = int.tryParse(postId) ?? postId; + final message = { + 'text': text, + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'elements': elements, + 'attaches': [], + }; + if (replyToMessageId != null) { + message['link'] = { + 'type': 'REPLY', + 'chatId': chatId, + 'postId': postIdField, + 'messageId': replyToMessageId, + }; + } + final payload = { + 'chatId': chatId, + 'postId': postIdField, + 'message': message, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) { + final raw = response.payload; + final msg = (raw is Map) + ? (raw['localizedMessage'] ?? raw['message'] ?? 'Ошибка отправки') + : 'Ошибка отправки'; + throw Exception(msg.toString()); + } + final data = response.payload; + if (data is Map) { + final msgMap = data['message']; + if (msgMap is Map) { + final id = msgMap['id']; + if (id != null) return id.toString(); + } + } + return ''; + } + + void sendTyping(int chatId, String postId, String type) { + unawaited(() async { + try { + await _api.sendRequest(Opcode.msgTyping, { + 'chatId': chatId, + 'postId': int.tryParse(postId) ?? postId, + 'type': type, + }); + } catch (_) {} + }()); + } + + CachedMessage? _parseComment( + Map m, + int accountId, + int chatId, + String postId, + ) { + final id = m['id']?.toString(); + if (id == null) return null; + + final full = Map.from(m.cast()); + full['postId'] = postId; + final parsed = CachedMessage.parseAttachments(full); + final senderId = _parseIntField(m['sender']); + + return CachedMessage( + id: id, + accountId: accountId, + chatId: chatId, + senderId: senderId, + text: m['text']?.toString(), + time: _parseIntField(m['time']), + status: m['status']?.toString(), + payload: full, + attachments: parsed.$1, + isControl: parsed.$2, + ); + } + + int _parseIntField(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) return int.tryParse(value) ?? 0; + return int.tryParse(value.toString()) ?? 0; + } +} diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index 193752f..0e8511b 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -124,6 +124,11 @@ abstract class Opcode { static const int linkInfo = 89; // Информация по ссылке / вход в канал static const int audioPlay = 301; // Воспроизведение аудио + // ── Comments (комментарии к постам каналов) ──────────────────────── + // Загрузка/отправка/набор комментариев переиспользуют chatHistory (49), + // msgSend (64) и msgTyping (65) с добавленным полем postId. + static const int commentsInfo = 91; // Кол-во комментариев к постам (totalCount) + // ── Sessions ─────────────────────────────────────────────────────── static const int sessionsInfo = 96; // Запрос активных сессий static const int sessionsClose = 97; // Закрытие всех сессий @@ -319,6 +324,7 @@ abstract class Opcode { fileDownload: 'FILE_DOWNLOAD', linkInfo: 'LINK_INFO', audioPlay: 'AUDIO_PLAY', + commentsInfo: 'COMMENTS_INFO', sessionsInfo: 'SESSIONS_INFO', sessionsClose: 'SESSIONS_CLOSE', phoneBindRequest: 'PHONE_BIND_REQUEST', diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index bf42001..8de7d78 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -49,6 +49,10 @@ class ComposerInputBar extends StatelessWidget { this.channelSubscribed = true, this.channelSubscribing = false, this.onSubscribe, + this.showStickerButton = true, + this.showAttachButton = true, + this.forceSend = false, + this.hintText = 'Message', }); final String chatType; @@ -79,6 +83,10 @@ class ComposerInputBar extends StatelessWidget { final bool channelSubscribed; final bool channelSubscribing; final VoidCallback? onSubscribe; + final bool showStickerButton; + final bool showAttachButton; + final bool forceSend; + final String hintText; @override Widget build(BuildContext context) { @@ -207,17 +215,19 @@ class ComposerInputBar extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onToggleStickerPanel, - child: Icon( - Symbols.face, - color: mutedIcon, - size: 24, - weight: 400, + if (showStickerButton) ...[ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onToggleStickerPanel, + child: Icon( + Symbols.face, + color: mutedIcon, + size: 24, + weight: 400, + ), ), - ), - const SizedBox(width: 12), + const SizedBox(width: 12), + ], Expanded( child: Focus( onKeyEvent: (node, event) { @@ -245,7 +255,7 @@ class ComposerInputBar extends StatelessWidget { TextAlignVertical.center, contextMenuBuilder: contextMenuBuilder, decoration: InputDecoration( - hintText: 'Message', + hintText: hintText, hintStyle: TextStyle( color: cs.onSurfaceVariant, fontSize: 16, @@ -260,14 +270,15 @@ class ComposerInputBar extends StatelessWidget { ), ), ), - _AttachButton( - hasText: hasText, - onOpen: onOpenAttach, - onLongOpen: onOpenAttachScheduled, - uploadStatus: uploadStatus, - mutedIcon: mutedIcon, - cs: cs, - ), + if (showAttachButton) + _AttachButton( + hasText: hasText, + onOpen: onOpenAttach, + onLongOpen: onOpenAttachScheduled, + uploadStatus: uploadStatus, + mutedIcon: mutedIcon, + cs: cs, + ), ], ), ), @@ -366,7 +377,7 @@ class ComposerInputBar extends StatelessWidget { valueListenable: note.videoNoteMode, builder: (context, videoMode, _) { final sendMode = - hasText || locked; + hasText || locked || forceSend; final pill = _actionSurface( color: _flat ? Colors.transparent @@ -377,14 +388,15 @@ class ComposerInputBar extends StatelessWidget { : _frost ? AppFrost.inputTint(cs) : cs.surfaceContainerHighest, - onTap: hasText + onTap: (hasText || forceSend) ? onSendText : locked ? () => voiceRec.stop( cancel: false, ) : null, - onLongPress: hasText + onLongPress: + (hasText && !forceSend) ? onScheduleMessage : null, child: SizedBox( @@ -421,30 +433,32 @@ class ComposerInputBar extends StatelessWidget { active: recording && !locked, ); + final voiceEnabled = + !sendMode && !forceSend; return GestureDetector( - onTap: sendMode - ? null - : note.toggleMode, - onLongPressStart: sendMode - ? null - : (_) => videoMode + onTap: voiceEnabled + ? note.toggleMode + : null, + onLongPressStart: voiceEnabled + ? (_) => videoMode ? note.start() - : voiceRec.start(), - onLongPressMoveUpdate: sendMode - ? null - : (d) => videoMode + : voiceRec.start() + : null, + onLongPressMoveUpdate: + voiceEnabled + ? (d) => videoMode ? note.handleDrag( d.offsetFromOrigin, ) : voiceRec.handleDrag( d.offsetFromOrigin, - ), - onLongPressEnd: sendMode - ? null - : (_) => videoMode + ) + : null, + onLongPressEnd: voiceEnabled + ? (_) => videoMode ? note.handleEnd() - : voiceRec - .handleEnd(), + : voiceRec.handleEnd() + : null, child: visual, ); }, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 5b65ffa..c9b4e1c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -10,6 +10,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:komet/backend/modules/chat_preview.dart'; import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/backend/modules/comments.dart'; import 'package:komet/backend/modules/file_uploader.dart'; import 'package:komet/backend/modules/upload_notification_service.dart'; import 'package:komet/core/media/gallery_source.dart'; @@ -201,6 +202,8 @@ class ChatScreen extends StatefulWidget { final ForwardRequest? forwardRequest; final String? initialMessageId; final int? initialMessageTime; + final String? commentPostId; + final CachedMessage? postMessage; const ChatScreen({ super.key, @@ -213,6 +216,8 @@ class ChatScreen extends StatefulWidget { this.forwardRequest, this.initialMessageId, this.initialMessageTime, + this.commentPostId, + this.postMessage, }); @override @@ -251,6 +256,13 @@ class _ChatScreenState extends State StreamSubscription? _uploadSub; StreamSubscription? _pushSub; StreamSubscription? _messageEventSub; + StreamSubscription>? _commentsInfoSub; + StreamSubscription? _commentSub; + final Map _commentCounts = {}; + final Set _commentCountsRequested = {}; + bool get _commentsMode => widget.commentPostId != null; + bool _commentsLoadingMore = false; + bool _commentsHasMore = true; StreamSubscription? _connSub; final Map?>> _reactionNotifiers = {}; @@ -602,6 +614,16 @@ class _ChatScreenState extends State _messageEventSub = chats.messageEvents .where((e) => e.chatId == widget.chatId) .listen(_onMessageEvent); + if (_commentsMode) { + _commentSub = commentsModule.commentStream + .where( + (e) => + e.chatId == widget.chatId && e.postId == widget.commentPostId, + ) + .listen(_onLiveComment); + } else if (widget.chatType == 'CHANNEL') { + _commentsInfoSub = commentsModule.infoStream.listen(_onCommentsInfo); + } ChatActivityStore.instance .listenable(widget.chatId) .addListener(_recomputeHeaderStatus); @@ -641,6 +663,7 @@ class _ChatScreenState extends State } Future _loadParticipantsCount() async { + if (_commentsMode) return; if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return; final info = await chats.getChatInfo(api, widget.chatId); if (!mounted) return; @@ -682,6 +705,7 @@ class _ChatScreenState extends State if (myName.isNotEmpty) ContactCache.put(p.id, myName); ContactCache.putAvatar(p.id, p.baseUrl); } + if (_commentsMode) return; _restoreDraft(); unawaited(_loadPeerKind()); unawaited(_loadWallpaper()); @@ -868,6 +892,15 @@ class _ChatScreenState extends State return; } if (_positioningInFlight) return; + if (_commentsMode) { + _initialPositionDone = true; + _markPositioned(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + return; + } if (_messages.isEmpty) { if (!_hasMoreHistory) _markPositioned(); return; @@ -1023,6 +1056,7 @@ class _ChatScreenState extends State } void _updateReadMarker() { + if (_commentsMode) return; if (!mounted || _myId == 0 || _messages.isEmpty) return; if (_awaitingPosition || !_initialPositionDone) return; if (!_scrollController.hasClients) return; @@ -1334,6 +1368,10 @@ class _ChatScreenState extends State if (!mounted) return; _myId = activeProfile?.id ?? 0; } + if (_commentsMode) { + await _loadCommentsHistory(); + return; + } if (widget.chatType == 'DIALOG') { unawaited(_loadOtherPresence()); } @@ -1358,6 +1396,14 @@ class _ChatScreenState extends State if (!_scrollController.hasClients) return; if (_historyAutoloadSuppressed) return; if (_isLoading) return; + if (_commentsMode) { + if (_commentsLoadingMore || !_commentsHasMore || _messages.isEmpty) return; + final pos = _scrollController.position; + if (pos.pixels - pos.minScrollExtent <= _historyPrefetchExtent) { + unawaited(_loadMoreComments()); + } + return; + } _maybeFillGap(); if (_isLoadingMore || !_hasMoreHistory) return; if (_messages.isEmpty) return; @@ -1518,9 +1564,158 @@ class _ChatScreenState extends State _pruneReactionNotifiers(); _chatController.persistSessionCache(); _reapplyPinIfNeeded(); + _requestCommentCounts(); } } + void _requestCommentCounts() { + if (_commentsMode) return; + if ((chat?.type ?? widget.chatType) != 'CHANNEL') return; + final pending = []; + for (final m in _messages) { + if (m.isControl) continue; + if (_commentCountsRequested.contains(m.id)) continue; + _commentCountsRequested.add(m.id); + pending.add(m.id); + } + if (pending.isEmpty) return; + unawaited( + commentsModule.fetchInfo( + accountId: _myId, + chatId: widget.chatId, + postIds: pending, + ), + ); + } + + void _onCommentsInfo(Map info) { + if (!mounted) return; + var changed = false; + for (final m in _messages) { + final count = info[m.id]?.totalCount; + if (count == null) continue; + if (_commentCounts[m.id] != count) { + _commentCounts[m.id] = count; + changed = true; + } + } + if (changed) setState(() {}); + } + + String _commentsLabelFor(String postId) { + final count = _commentCounts[postId]; + if (count == null || count == 0) return 'Комментировать'; + if (count == 1) return '1 комментарий'; + if (count % 10 == 1 && count % 100 != 11) return '$count комментарий'; + return '$count комментариев'; + } + + void _openComments(CachedMessage post) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: 'CHANNEL', + commentPostId: post.id, + postMessage: _stripInlineKeyboard(post), + ), + ), + ); + } + + CachedMessage _stripInlineKeyboard(CachedMessage post) { + final attaches = post.attachments; + if (attaches == null || attaches.isEmpty) return post; + final filtered = attaches + .where((a) => a.type != AttachmentType.inlineKeyboard) + .toList(); + if (filtered.length == attaches.length) return post; + return post.copyWith(attachments: filtered); + } + + Future _loadCommentsHistory() async { + final post = widget.postMessage; + final loaded = await commentsModule.fetchHistory( + _myId, + widget.chatId, + widget.commentPostId!, + fromTime: post?.time ?? DateTime.now().millisecondsSinceEpoch, + forward: 30, + backward: 0, + ); + if (!mounted) return; + final comments = [...loaded]..sort((a, b) => a.time.compareTo(b.time)); + _messages = post != null ? [post, ...comments] : comments; + _commentsHasMore = comments.isNotEmpty; + _syncReactionNotifiersFromMessages(); + unawaited(_resolveCommentNames(comments)); + _bumpMessages(); + setState(() { + _isLoading = false; + _onLoadingFinished(); + }); + } + + Future _loadMoreComments() async { + if (_commentsLoadingMore || !_commentsHasMore || _messages.isEmpty) return; + _commentsLoadingMore = true; + final newest = _messages.last; + try { + final more = await commentsModule.fetchHistory( + _myId, + widget.chatId, + widget.commentPostId!, + fromTime: newest.time, + forward: 30, + backward: 0, + ); + if (!mounted) return; + final existing = _messages.map((m) => m.id).toSet(); + final fresh = more.where((c) => !existing.contains(c.id)).toList(); + if (fresh.isEmpty) { + _commentsHasMore = false; + } else { + _messages = [..._messages, ...fresh] + ..sort((a, b) => a.time.compareTo(b.time)); + _syncReactionNotifiersFromMessages(); + unawaited(_resolveCommentNames(fresh)); + _bumpMessages(); + } + } finally { + _commentsLoadingMore = false; + } + } + + void _onLiveComment(CommentAddedEvent event) { + if (!mounted) return; + final comment = event.comment; + if (comment.senderId == _myId) return; + if (_messages.any((m) => m.id == comment.id)) return; + final nearBottom = _isNearListBottom(); + _messages.add(comment); + _syncReactionNotifiersFromMessages(); + _bumpMessages(); + unawaited(_resolveCommentNames([comment])); + if (nearBottom) _scrollToBottom(); + } + + bool _isNearListBottom() { + if (!_scrollController.hasClients) return true; + return _scrollController.position.pixels <= _historyPrefetchExtent; + } + + Future _resolveCommentNames(List list) async { + final ids = list + .map((m) => m.senderId) + .where((id) => id != 0 && ContactCache.get(id) == null) + .toSet(); + if (ids.isEmpty) return; + final resolved = await messagesModule.ensureContactNames(ids); + if (resolved && mounted) _bumpMessages(); + } + void _syncReactionNotifiersFromMessages() { for (final m in _messages) { if (_reactionNotifiers.containsKey(m.id)) continue; @@ -1601,6 +1796,8 @@ class _ChatScreenState extends State _uploadSub?.cancel(); _pushSub?.cancel(); _messageEventSub?.cancel(); + _commentsInfoSub?.cancel(); + _commentSub?.cancel(); _connSub?.cancel(); _voiceRec.dispose(); _note.dispose(); @@ -1668,7 +1865,9 @@ class _ChatScreenState extends State } void _restoreDraft() { - if (_myId == 0 || _messageController.text.isNotEmpty) return; + if (_myId == 0 || _commentsMode || _messageController.text.isNotEmpty) { + return; + } final draft = DraftStore.instance.get(_myId, widget.chatId); if (draft == null || draft.isEmpty) return; _messageController.text = draft; @@ -1678,7 +1877,7 @@ class _ChatScreenState extends State } void _saveDraft() { - if (_myId == 0) return; + if (_myId == 0 || _commentsMode) return; unawaited( DraftStore.instance.set( _myId, @@ -2146,7 +2345,7 @@ class _ChatScreenState extends State }, ), ComposerInputBar( - chatType: widget.chatType, + chatType: _commentsMode ? 'CHAT' : widget.chatType, chrome: _effectiveChrome, style: AppComposerStyle.current.value, background: AppComposerBackground.current.value, @@ -2175,6 +2374,10 @@ class _ChatScreenState extends State channelSubscribed: !_previewChat, channelSubscribing: _subscribing, onSubscribe: _subscribeChannel, + showStickerButton: !_commentsMode, + showAttachButton: !_commentsMode, + forceSend: _commentsMode, + hintText: _commentsMode ? 'Комментарий' : 'Message', ), StickerPanelView( stickers: _stickers, @@ -2502,6 +2705,7 @@ class _ChatScreenState extends State void _onMessageEvent(MessageEvent event) { if (!mounted) return; + if (_commentsMode) return; switch (event) { case MessageAddedEvent(:final message): if (message.senderId == _myId) return; @@ -2662,7 +2866,7 @@ class _ChatScreenState extends State cs: cs, embedded: widget.embedded, chatId: widget.chatId, - name: widget.name, + name: _commentsMode ? 'Комментарии' : widget.name, imageUrl: widget.imageUrl, chatType: widget.chatType, isOfficial: chat?.isOfficial ?? false, @@ -2670,13 +2874,14 @@ class _ChatScreenState extends State headerStatus: _headerStatusNotifier, scheduledCount: _scheduledCount, otherUnread: _otherUnread, - showCall: - widget.chatType == 'DIALOG' && !_peerIsBot, + showCall: !_commentsMode && + widget.chatType == 'DIALOG' && + !_peerIsBot, onClose: widget.onClose, - onOpenInfo: _openChatInfo, + onOpenInfo: _commentsMode ? () {} : _openChatInfo, onOpenScheduled: _openScheduledMessages, onCall: _startCall, - onMenu: _openChatMenu, + onMenu: _commentsMode ? (_) {} : _openChatMenu, ), ), ), @@ -3034,6 +3239,10 @@ class _ChatScreenState extends State } void _recomputeHeaderStatus() { + if (_commentsMode) { + _headerStatusNotifier.value = ''; + return; + } _headerStatusNotifier.value = _headerStatus(); } @@ -3249,22 +3458,25 @@ class _ChatScreenState extends State _lastSentId = tempId; _messages.add(composed); _messageController.clear(); - if (DraftStore.instance.get(_myId, widget.chatId) != null) { + if (!_commentsMode && + DraftStore.instance.get(_myId, widget.chatId) != null) { unawaited(DraftStore.instance.clear(_myId, widget.chatId)); } _bumpMessages(); - unawaited(_persistOutgoing(composed)); - unawaited( - chats.applyOutgoing( - _myId, - widget.chatId, - messageId: tempId, - time: now, - text: text, - status: composed.status ?? 'sending', - elements: elements, - ), - ); + if (!_commentsMode) { + unawaited(_persistOutgoing(composed)); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: tempId, + time: now, + text: text, + status: composed.status ?? 'sending', + elements: elements, + ), + ); + } // Instant tactile "whoosh" the moment the message leaves the composer, // not after the network round-trip — feedback must feel immediate. @@ -3276,13 +3488,22 @@ class _ChatScreenState extends State if (!online) return; try { - final actualId = await messagesModule.sendMessage( - _myId, - widget.chatId, - text, - replyToMessageId: replyId, - elements: elements, - ); + final actualId = _commentsMode + ? await commentsModule.sendComment( + _myId, + widget.chatId, + widget.commentPostId!, + text, + replyToMessageId: replyId, + elements: elements, + ) + : await messagesModule.sendMessage( + _myId, + widget.chatId, + text, + replyToMessageId: replyId, + elements: elements, + ); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { @@ -3298,21 +3519,23 @@ class _ChatScreenState extends State ); _messages[index] = sent; _bumpMessages(); - unawaited(_persistOutgoing(sent, removeId: tempId)); - unawaited( - chats.applyOutgoing( - _myId, - widget.chatId, - messageId: sent.id, - time: now, - text: text, - status: 'sent', - elements: elements, - ), - ); + if (!_commentsMode) { + unawaited(_persistOutgoing(sent, removeId: tempId)); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: sent.id, + time: now, + text: text, + status: 'sent', + elements: elements, + ), + ); + } } - if (chat == null) { + if (!_commentsMode && chat == null) { unawaited( chats.refreshChats(api, [widget.chatId]).then((list) { if (!mounted || list.isEmpty) return; @@ -3337,18 +3560,20 @@ class _ChatScreenState extends State ); _messages[index] = queued; _bumpMessages(); - unawaited(_persistOutgoing(queued)); - unawaited( - chats.applyOutgoing( - _myId, - widget.chatId, - messageId: tempId, - time: now, - text: text, - status: 'pending', - elements: elements, - ), - ); + if (!_commentsMode) { + unawaited(_persistOutgoing(queued)); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: tempId, + time: now, + text: text, + status: 'pending', + elements: elements, + ), + ); + } } } } @@ -4733,13 +4958,20 @@ class _ChatScreenState extends State ? _messages[msgIndex + 1] : null; + final bool isChannelPost = + !_commentsMode && + (chat?.type ?? widget.chatType) == 'CHANNEL' && + !message.isControl; + final bubble = MessageBubble( message: message, isMe: isMe, myId: _myId, prevMessage: prevMessage, nextMessage: nextMessage, - chatType: chat?.type ?? 'CHAT', + chatType: _commentsMode + ? 'CHAT' + : (chat?.type ?? 'CHAT'), chatId: widget.chatId, photoActions: _photoActions, overrideStatus: _effectiveStatus(message), @@ -4760,6 +4992,12 @@ class _ChatScreenState extends State peerAvatarUrl: widget.imageUrl, textSelection: _textSelection, onExitTextSelection: _exitTextSelection, + commentsLabel: isChannelPost + ? _commentsLabelFor(message.id) + : null, + onCommentsTap: isChannelPost + ? () => _openComments(message) + : null, ); final canReport = !isMe && !message.isControl; @@ -6643,3 +6881,4 @@ class _ChatMessageListState extends State<_ChatMessageList> { ); } } + diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 62609a6..e35b4f9 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -113,6 +113,7 @@ void showMessageActions({ Future> Function()? loadReportReasons, Future Function(int reasonId)? onReport, VoidCallback? onDelete, + bool allowDelete = true, VoidCallback? onEdit, VoidCallback? onReply, VoidCallback? onForward, @@ -151,6 +152,7 @@ void showMessageActions({ loadReportReasons: loadReportReasons, onReport: onReport, onDelete: onDelete, + allowDelete: allowDelete, onEdit: onEdit, onReply: onReply, onForward: onForward, @@ -186,6 +188,7 @@ class _MessageActionsLayer extends StatefulWidget { final Future> Function()? loadReportReasons; final Future Function(int reasonId)? onReport; final VoidCallback? onDelete; + final bool allowDelete; final VoidCallback? onEdit; final VoidCallback? onReply; final VoidCallback? onForward; @@ -213,6 +216,7 @@ class _MessageActionsLayer extends StatefulWidget { this.loadReportReasons, this.onReport, this.onDelete, + this.allowDelete = true, this.onEdit, this.onReply, this.onForward, @@ -525,12 +529,13 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _showReportView, destructive: true, ), - _Action( - Symbols.delete, - l10n.msgActionsDelete, - _delete, - destructive: true, - ), + if (widget.allowDelete) + _Action( + Symbols.delete, + l10n.msgActionsDelete, + _delete, + destructive: true, + ), ]; } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 16fecbe..877c378 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -203,6 +203,8 @@ class MessageBubble extends StatelessWidget { final String? peerAvatarUrl; final ValueListenable<({String id, Offset pos})?>? textSelection; final VoidCallback? onExitTextSelection; + final String? commentsLabel; + final VoidCallback? onCommentsTap; const MessageBubble({ super.key, @@ -226,6 +228,8 @@ class MessageBubble extends StatelessWidget { this.peerAvatarUrl, this.textSelection, this.onExitTextSelection, + this.commentsLabel, + this.onCommentsTap, }); bool _computeHasPhotoWithCaption() { @@ -619,6 +623,29 @@ class MessageBubble extends StatelessWidget { ); } + final bool hasCommentsFooter = onCommentsTap != null; + final EdgeInsets containerPadding = hasCommentsFooter + ? EdgeInsets.zero + : padding; + + final Widget innerContent = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showSenderName) + _buildSenderHeader(cs, padding == EdgeInsets.zero), + withReply( + reactionsInside + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [bubbleContent, _reactionsBar(cs)], + ) + : bubbleContent, + ), + ], + ); + final Widget bubbleBox = ListenableBuilder( listenable: Listenable.merge([ AppBubbleShape.current, @@ -638,26 +665,24 @@ class MessageBubble extends StatelessWidget { hasMultiPhotos, ), ), - padding: padding, + padding: containerPadding, child: child, ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showSenderName) - _buildSenderHeader(cs, padding == EdgeInsets.zero), - withReply( - reactionsInside - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [bubbleContent, _reactionsBar(cs)], - ) - : bubbleContent, - ), - ], - ), + child: hasCommentsFooter + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: padding == EdgeInsets.zero + ? const EdgeInsets.symmetric(horizontal: 14, vertical: 10) + : padding, + child: innerContent, + ), + _buildCommentsFooter(cs), + ], + ) + : innerContent, ); return Padding( @@ -703,6 +728,54 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildCommentsFooter(ColorScheme cs) { + final label = commentsLabel ?? 'Комментарии'; + final accent = isMe ? cs.onPrimaryContainer : cs.primary; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onCommentsTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Divider( + height: 0.5, + thickness: 0.5, + color: cs.onSurfaceVariant.withValues(alpha: 0.18), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 11, + ), + child: Row( + children: [ + Icon(Symbols.mode_comment, size: 19, color: accent), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: accent, + ), + ), + ), + Icon( + Symbols.chevron_right, + size: 20, + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + ), + ], + ), + ), + ], + ), + ), + ); + } + Widget _buildInlineKeyboard( BuildContext context, ColorScheme cs, diff --git a/lib/main.dart b/lib/main.dart index f3d0a14..d73dd33 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,6 +50,7 @@ import 'core/config/app_theme_schedule.dart'; import 'core/config/app_digital_id_mode.dart'; import 'backend/modules/account.dart'; import 'backend/modules/chats.dart'; +import 'backend/modules/comments.dart'; import 'backend/modules/contacts.dart'; import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; @@ -86,6 +87,7 @@ import 'frontend/widgets/theme_reveal.dart'; final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); +final commentsModule = CommentsModule(api); final sharedContentModule = SharedContentModule(api); final pollsModule = PollsModule(api); final stickersModule = StickersModule(api); @@ -183,6 +185,7 @@ void main(List args) async { } attachInfoCacheApi(api); chats.attachGlobalPushHandlers(api); + commentsModule.attachPushHandlers(api); storiesModule.attach(); unawaited(storiesModule.loadCache()); unawaited(DeepLinkService.instance.init());