feat: комментарии, немного сырые

This commit is contained in:
Jganenokk
2026-07-16 00:22:02 +07:00
parent f7604ebf34
commit cf8e50c6f7
8 changed files with 772 additions and 116 deletions
+9
View File
@@ -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;
+307
View File
@@ -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<int> revision = ValueNotifier<int>(0);
final _infoController =
StreamController<Map<String, CommentsInfo>>.broadcast();
Stream<Map<String, CommentsInfo>> get infoStream => _infoController.stream;
final _commentController = StreamController<CommentAddedEvent>.broadcast();
Stream<CommentAddedEvent> get commentStream => _commentController.stream;
Map<String, CommentsInfo> _info = <String, CommentsInfo>{};
Map<String, CommentsInfo> 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<Packet>? _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<dynamic, dynamic>(),
_accountId,
chatId,
postId,
);
if (comment == null) return;
_commentController.add(CommentAddedEvent(chatId, postId, comment));
}
void handleInfoUpdate(List updates) {
if (updates.isEmpty) return;
Map<String, CommentsInfo>? 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<String, dynamic>.from(commentsInfo.cast()),
);
next ??= Map<String, CommentsInfo>.from(_info);
next[postId] = updated;
}
if (next == null) return;
_info = next;
revision.value = revision.value + 1;
_infoController.add(Map.unmodifiable(_info));
}
Future<Map<String, CommentsInfo>> fetchInfo({
required int accountId,
required int chatId,
required List<String> 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 = <String, CommentsInfo>{};
for (final raw in updates.whereType<Map>()) {
final postId = raw['postId']?.toString();
final commentsInfo = raw['commentsInfo'];
if (postId == null || commentsInfo is! Map) continue;
byPost[postId] = CommentsInfo.fromPayload(
postId,
Map<String, dynamic>.from(commentsInfo.cast()),
);
}
return byPost;
}
Future<List<CachedMessage>> fetchHistory(
int accountId,
int chatId,
String postId, {
required int fromTime,
int forward = 30,
int backward = 15,
}) async {
_accountId = accountId;
final payload = <String, dynamic>{
'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 = <CachedMessage>[];
for (var i = 0; i < messagesData.length; i++) {
final m = messagesData[i];
if (m is! Map) continue;
final parsed = _parseComment(
m.cast<dynamic, dynamic>(),
accountId,
chatId,
postId,
);
if (parsed != null) results.add(parsed);
if (i > 0 && i % 20 == 0) await Future<void>.delayed(Duration.zero);
}
return results;
}
Future<String> sendComment(
int accountId,
int chatId,
String postId,
String text, {
bool notify = true,
int? replyToMessageId,
List<Map<String, dynamic>> elements = const [],
}) async {
_accountId = accountId;
final Object postIdField = int.tryParse(postId) ?? postId;
final message = <String, dynamic>{
'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 = <String, dynamic>{
'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<dynamic, dynamic> m,
int accountId,
int chatId,
String postId,
) {
final id = m['id']?.toString();
if (id == null) return null;
final full = Map<String, dynamic>.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;
}
}
+6
View File
@@ -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',
@@ -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,
);
},
+293 -54
View File
@@ -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<ChatScreen>
StreamSubscription<UploadEvent>? _uploadSub;
StreamSubscription<Packet>? _pushSub;
StreamSubscription<MessageEvent>? _messageEventSub;
StreamSubscription<Map<String, CommentsInfo>>? _commentsInfoSub;
StreamSubscription<CommentAddedEvent>? _commentSub;
final Map<String, int> _commentCounts = {};
final Set<String> _commentCountsRequested = {};
bool get _commentsMode => widget.commentPostId != null;
bool _commentsLoadingMore = false;
bool _commentsHasMore = true;
StreamSubscription<SessionState>? _connSub;
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers =
{};
@@ -602,6 +614,16 @@ class _ChatScreenState extends State<ChatScreen>
_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<ChatScreen>
}
Future<void> _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<ChatScreen>
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<ChatScreen>
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<ChatScreen>
}
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<ChatScreen>
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<ChatScreen>
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<ChatScreen>
_pruneReactionNotifiers();
_chatController.persistSessionCache();
_reapplyPinIfNeeded();
_requestCommentCounts();
}
}
void _requestCommentCounts() {
if (_commentsMode) return;
if ((chat?.type ?? widget.chatType) != 'CHANNEL') return;
final pending = <String>[];
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<String, CommentsInfo> 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<void> _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<void> _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<void> _resolveCommentNames(List<CachedMessage> 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<ChatScreen>
_uploadSub?.cancel();
_pushSub?.cancel();
_messageEventSub?.cancel();
_commentsInfoSub?.cancel();
_commentSub?.cancel();
_connSub?.cancel();
_voiceRec.dispose();
_note.dispose();
@@ -1668,7 +1865,9 @@ class _ChatScreenState extends State<ChatScreen>
}
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<ChatScreen>
}
void _saveDraft() {
if (_myId == 0) return;
if (_myId == 0 || _commentsMode) return;
unawaited(
DraftStore.instance.set(
_myId,
@@ -2146,7 +2345,7 @@ class _ChatScreenState extends State<ChatScreen>
},
),
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<ChatScreen>
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<ChatScreen>
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<ChatScreen>
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<ChatScreen>
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<ChatScreen>
}
void _recomputeHeaderStatus() {
if (_commentsMode) {
_headerStatusNotifier.value = '';
return;
}
_headerStatusNotifier.value = _headerStatus();
}
@@ -3249,22 +3458,25 @@ class _ChatScreenState extends State<ChatScreen>
_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<ChatScreen>
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<ChatScreen>
);
_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<ChatScreen>
);
_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<ChatScreen>
? _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<ChatScreen>
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> {
);
}
}
@@ -113,6 +113,7 @@ void showMessageActions({
Future<List<({int id, String title})>> Function()? loadReportReasons,
Future<bool> 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<List<({int id, String title})>> Function()? loadReportReasons;
final Future<bool> 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,
),
];
}
+91 -18
View File
@@ -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,
+3
View File
@@ -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<String> args) async {
}
attachInfoCacheApi(api);
chats.attachGlobalPushHandlers(api);
commentsModule.attachPushHandlers(api);
storiesModule.attach();
unawaited(storiesModule.loadCache());
unawaited(DeepLinkService.instance.init());