Files
IKAR/lib/frontend/screens/chats/chat_screen.dart
T
sevenhill 1ef2b7420e
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Port Komet UI improvements and implement IKAR ghost mode
Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
2026-10-07 22:07:05 +03:00

6759 lines
214 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:ikar/frontend/widgets/toast_placement.dart';
import 'package:ikar/frontend/widgets/hint_bubble.dart';
import 'chat/view/message_row_widgets.dart' show SelectableMessageRow;
import '../../../core/config/ios_release.dart';
import '../../../backend/modules/forward_sender.dart';
export '../../../backend/modules/forward_sender.dart' show ForwardRequest;
import '../../../core/links/message_link_token.dart';
import '../../widgets/call_link_handler.dart';
import '../../widgets/no_chat_access_card.dart';
import 'chat/view/chat_call_banner.dart';
import 'chat/view/greeting_sticker_card.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import '../../commands/plugin_commands.dart' as plugins;
import '../../../core/plugins/plugin_models.dart';
import '../../../core/plugins/plugin_runtime.dart';
import 'dart:async';
import 'dart:convert' show base64Encode, utf8;
import 'package:komet_crypto/komet_crypto.dart' show ContentType;
import '../../../core/crypto/e2ee_service.dart';
import '../../widgets/e2ee_banner.dart';
import 'e2ee_screen.dart';
import 'dart:io' show File;
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:ikar/backend/modules/chat_preview.dart';
import 'package:ikar/backend/modules/chats.dart';
import 'package:ikar/backend/modules/comments.dart';
import 'package:ikar/backend/modules/upload_service.dart';
import 'package:ikar/backend/modules/webapp.dart';
import 'package:ikar/frontend/screens/webapp/open_mini_app.dart';
import 'package:ikar/frontend/widgets/sending_clock_icon.dart';
import 'package:ikar/core/media/desktop_video_probe.dart';
import 'package:ikar/core/media/video_transcoder.dart';
import 'package:ikar/core/media/clipboard/clipboard_media.dart';
import 'package:ikar/core/media/clipboard/pasted_attachment.dart';
import 'package:ikar/frontend/widgets/paste_media_toolbar.dart';
import 'package:ikar/core/media/gallery_source.dart';
import 'package:ikar/core/media/send_media_separately.dart';
import 'package:ikar/core/utils/format.dart';
import 'package:ikar/frontend/screens/chats/chat_info_screen.dart';
import 'package:ikar/frontend/screens/contacts/open_contact_profile.dart';
import 'package:ikar/frontend/screens/chats/chat_list_screen.dart';
import 'package:ikar/frontend/screens/chats/poll_create_screen.dart';
import 'package:ikar/frontend/widgets/animated_text_swap.dart';
import 'package:ikar/frontend/widgets/custom_notification.dart';
import '../../widgets/undo_notification.dart';
import '../../widgets/scroll_keyboard_dismiss.dart';
import '../../widgets/swipe_to_reply.dart';
import '../../../core/security/app_lock.dart';
import 'package:ikar/frontend/widgets/chat_menu_overlay.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart';
import '../../../l10n/app_localizations.dart';
import '../../../backend/api.dart';
import '../../../backend/modules/messages.dart';
import '../../../backend/modules/outbox.dart';
import '../../../backend/modules/outgoing_text.dart';
import '../../../backend/modules/contacts.dart';
import '../../../models/animoji.dart';
import '../../../backend/modules/complaints.dart';
import '../../../core/calls/call_controller.dart';
import '../../../core/media/tlottie/tlottie.dart';
import '../calls/call_screen.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/push/notification_bridge.dart';
import '../../../core/push/push_service.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/chat_activity_store.dart';
import '../../../core/storage/chat_members_store.dart';
import '../../../core/crypto/chat_crypto_service.dart';
import '../../../core/crypto/encrypted_photo.dart';
import '../../../core/crypto/message_decryption_cache.dart';
import '../../../core/storage/chat_encryption_store.dart';
import '../../../core/storage/chat_wallpaper_store.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/storage/archived_chats_store.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/cache/message_session_cache.dart';
import '../../../core/utils/haptics.dart';
import '../../../core/utils/emoji_keyword_index.dart';
import '../../../models/admin_rights.dart';
import '../../../models/chat_reaction_settings.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/route_settle.dart';
import '../../../core/config/app_cache_extent.dart';
import '../../../core/config/app_swipe_back_desktop.dart';
import 'chat/chat_prank_controller.dart';
import 'chat/chat_controller.dart';
import 'chat/view/anchored_message_list.dart';
import 'chat/view/scroll_down_button.dart';
import 'chat/read_marker_gate.dart';
import 'chat/unread_message_count.dart';
import 'chat/voice_record_controller.dart';
import 'chat/video_note_controller.dart';
import 'chat/command_panel_controller.dart';
import 'chat/sticker_panel_controller.dart';
import 'chat/chat_search_controller.dart';
import 'chat/message_search_result.dart';
import 'chat/typing_label.dart';
import 'chat/view/chat_intro_cards.dart';
import 'chat/upload_status.dart';
import 'chat/view/search_view.dart';
import 'chat/view/composer_input.dart';
import 'chat/view/sticker_panel_view.dart';
import 'chat/view/command_panel_view.dart';
import 'chat/view/command_arguments_form.dart';
import 'chat/view/mention_panel_view.dart';
import 'chat/mention_panel_controller.dart';
import 'chat/view/selection_bar.dart';
import 'chat/view/chat_header.dart';
import 'chat/view/shimmer_loading.dart';
import '../../../core/config/app_commands.dart';
import '../../../core/config/app_visual_style.dart';
import '../../../core/config/app_chat_chrome.dart';
import 'package:ikar/core/config/app_composer_background.dart';
import 'package:ikar/core/config/app_frost.dart';
import 'package:ikar/core/config/app_composer_style.dart';
import '../../../core/config/qlyra_settings.dart';
import '../../../models/attachment.dart';
import '../../../models/contact_info.dart';
import '../../../models/sticker.dart';
import '../../commands/command_registry.dart';
import '../../commands/slash_command.dart';
import '../../widgets/rich_message_controller.dart';
import '../../../core/utils/text_format.dart';
import '../../widgets/confirm_dialog.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/message_bubble.dart';
import '../../widgets/photo_viewer.dart';
import '../../widgets/message_actions_overlay.dart';
import '../../widgets/lottie_image.dart';
import '../../widgets/attachment_panel.dart';
import '../../widgets/attachment/attachment_sheet.dart';
import '../../widgets/attachment/paste_preview_sheet.dart';
import '../../widgets/sticker_pack_sheet.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/swipe_to_pop.dart';
import '../../widgets/swipe_route.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/schedule_time_picker.dart';
import '../../widgets/chat_wallpaper_sheet.dart';
import '../../widgets/chat_wallpaper_view.dart';
import '../../widgets/liquid_glass.dart';
import 'scheduled_messages_screen.dart';
import 'chat_encryption_screen.dart';
import 'chat_wallpaper_preview_screen.dart';
import 'chat/location_attachment_controller.dart';
import 'profile_action_sheets.dart';
import '../../../core/media/media_playback.dart';
import '../../widgets/media_playback_pill.dart';
import '../../../core/config/app_fonts.dart';
import '../../../core/config/app_shape.dart';
part 'chat/chat_screen_support.dart';
part 'chat/chat_text_sending.dart';
part 'chat/chat_plugins.dart';
part 'chat/chat_clipboard.dart';
part 'chat/chat_media_sending.dart';
class ChatScreen extends StatefulWidget {
final int chatId;
final String name;
final String imageUrl;
final String chatType;
final bool? channelSubscribed;
final bool embedded;
final bool preview;
final VoidCallback? onClose;
final ForwardRequest? forwardRequest;
final ReplyRequest? replyRequest;
final String? initialMessageId;
final int? initialMessageTime;
final String? commentPostId;
final CachedMessage? postMessage;
final String? botStartPayload;
final String? initialText;
const ChatScreen({
super.key,
required this.chatId,
required this.name,
required this.imageUrl,
required this.chatType,
this.channelSubscribed,
this.embedded = false,
this.preview = false,
this.onClose,
this.forwardRequest,
this.replyRequest,
this.initialMessageId,
this.initialMessageTime,
this.commentPostId,
this.postMessage,
this.botStartPayload,
this.initialText,
});
static final List<_ChatScreenState> _open = [];
static bool revealOpenChat(int chatId) {
for (final screen in _open.reversed) {
if (screen.widget.chatId != chatId || !screen.mounted) continue;
final route = ModalRoute.of(screen.context);
if (route == null) return false;
Navigator.of(screen.context).popUntil((r) => r == route);
return true;
}
return false;
}
static bool startBotInVisibleChat(int chatId, String startPayload) {
for (final screen in _open.reversed) {
if (screen.widget.chatId != chatId) continue;
if (!screen.mounted || !screen._isRouteCurrent) continue;
unawaited(screen._sendBotStart(startPayload));
return true;
}
return false;
}
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen>
with TickerProviderStateMixin, WidgetsBindingObserver, ReloadOnReconnect {
final RichMessageController _messageController = RichMessageController();
final FocusNode _messageFocusNode = FocusNode();
double _keyboardReserve = 0;
bool _keyboardWasOpen = false;
bool _keyboardBeforeStickers = false;
final ScrollController _scrollController = ScrollController();
bool _userDidScroll = false;
int _userGestureEpoch = 0;
String? _pinnedMessageId;
double _pinnedAlignment = 0;
int? _unreadAnchorTime;
bool _awaitingPosition = false;
bool _navigatingToTarget = false;
bool _initialPositionDone = false;
bool _positioningInFlight = false;
bool _initialTargetHandled = false;
int _historyAutoloadSuppressCount = 0;
bool get _historyAutoloadSuppressed => _historyAutoloadSuppressCount > 0;
int _readMarkTime = 0;
late final ReadMarkerGate _readMarker = ReadMarkerGate(
onFlush: _updateReadMarker,
);
final GlobalKey _listKey = GlobalKey();
final GlobalKey _unreadSeparatorKey = GlobalKey();
final Object _profileHeroTag = UniqueKey();
final ValueNotifier<bool> _hasText = ValueNotifier(false);
bool _isLoading = true;
bool _encryptionEnabled = false;
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
bool _pastePending = false;
late final StickerPanelController _stickers;
final ValueNotifier<UploadStatus> _uploadStatus = ValueNotifier(
const UploadStatus(),
);
String? _uploadStatusJobId;
ValueListenable<UploadBytes>? _uploadStatusBytes;
StreamSubscription<Packet>? _pushSub;
StreamSubscription<Object>? _reactionUpdatesSub;
StreamSubscription<List<CachedMessage>>? _forwardedSub;
StreamSubscription<int>? _departedSub;
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 =
{};
final ValueNotifier<ReactionAnimationEvent?> _reactionAnimation =
ValueNotifier(null);
int _reactionAnimationToken = 0;
final ValueNotifier<int> _scheduledCount = ValueNotifier(0);
late final VoiceRecordController _voiceRec = VoiceRecordController(
contextOf: () => context,
isMounted: () => mounted,
myId: () => _myId,
onRecorded: _sendVoice,
);
late final VideoNoteController _note = VideoNoteController(
contextOf: () => context,
isMounted: () => mounted,
onRecorded: _sendVideoNote,
formatElapsed: formatVoiceElapsed,
bottomInset: () => _composerHeight.value,
);
StreamSubscription<UploadJobEvent>? _uploadEventSub;
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
UploadService.instance.progressFor(m.id);
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
final existing = _reactionNotifiers[m.id];
if (existing != null) return existing;
final info = m.payload?['reactionInfo'];
final notifier = ValueNotifier<Map<String, dynamic>?>(
info is Map ? Map<String, dynamic>.from(info) : null,
);
_reactionNotifiers[m.id] = notifier;
return notifier;
}
final ValueNotifier<ChatReactionSettings?> _reactionSettings = ValueNotifier(
null,
);
Future<void> _loadReactionSettings() async {
final type = widget.chatType;
if (_commentsMode) return;
if (type != 'CHAT' && type != 'GROUP' && type != 'CHANNEL') return;
_reactionUpdatesSub = chatAdminModule.reactionUpdates
.where((update) => update.chatId == widget.chatId)
.listen((update) => _reactionSettings.value = update.settings);
try {
final settings = await chatAdminModule.reactionSettingsFor(widget.chatId);
if (mounted) _reactionSettings.value = settings;
} catch (_) {}
}
void _onForwardedHere(List<CachedMessage> messages) {
if (!mounted) return;
var added = false;
for (final message in messages) {
if (message.chatId != widget.chatId || message.accountId != _myId) {
continue;
}
if (_messages.any((m) => m.id == message.id)) continue;
_messages.add(message);
added = true;
}
if (added) _bumpMessages();
}
bool _reactionAllowed(Map<String, dynamic>? current, String emoji) {
final settings = _reactionSettings.value;
if (settings == null) return true;
final yours = current?['yourReaction']?.toString();
if (yours != null &&
EmojiKeywordIndex.normalize(yours) ==
EmojiKeywordIndex.normalize(emoji)) {
return true;
}
return settings.allowsOn(
emoji,
ChatReactionSettings.presentWithoutMine(current),
);
}
void _reactToMessage(CachedMessage message, String emoji) {
if (message.isControl || message.id.startsWith('temp_')) return;
final notifier = _reactionNotifierFor(message);
final previous = notifier.value;
if (!_reactionAllowed(previous, emoji)) {
Haptics.error();
showHintBubble(
_messageKeys[message.id]?.currentContext ?? context,
AppLocalizations.of(context)!.reactionUnavailable,
);
return;
}
final applied = _applyLocalReaction(previous, emoji);
notifier.value = applied;
final isToggleOff = applied == null || applied['yourReaction'] == null;
unawaited(_sendReaction(message, emoji, isToggleOff, previous));
}
Future<void> _sendReaction(
CachedMessage message,
String emoji,
bool isToggleOff,
Map<String, dynamic>? previous,
) async {
({bool ok, Map<String, dynamic>? info}) result;
try {
result = isToggleOff
? await messagesModule.cancelReaction(widget.chatId, message.id)
: await messagesModule.setReaction(widget.chatId, message.id, emoji);
} catch (_) {
result = (ok: false, info: null);
}
if (!mounted) return;
final notifier = _reactionNotifiers[message.id];
if (notifier == null) return;
if (!result.ok) {
notifier.value = previous;
Haptics.error();
showHintBubble(
_messageKeys[message.id]?.currentContext ?? context,
AppLocalizations.of(context)!.chatScreenReactionUpdateFailed,
);
return;
}
notifier.value = result.info;
_applyReactionInfoToMessage(message.id, result.info);
final appliedReaction = result.info?['yourReaction']?.toString();
if (!isToggleOff &&
appliedReaction != null &&
EmojiKeywordIndex.normalize(appliedReaction) ==
EmojiKeywordIndex.normalize(emoji)) {
final event = ReactionAnimationEvent(
messageId: message.id,
emoji: appliedReaction,
token: ++_reactionAnimationToken,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _reactionAnimation.value = event;
});
}
}
void _applyReactionInfoToMessage(
String messageId,
Map<String, dynamic>? info,
) {
final idx = _messages.indexWhere((m) => m.id == messageId);
if (idx == -1) return;
final payload = <String, dynamic>{...?_messages[idx].payload};
if (info == null) {
payload.remove('reactionInfo');
} else {
payload['reactionInfo'] = info;
}
_messages[idx] = _messages[idx].copyWith(payload: payload);
}
Map<String, dynamic>? _applyLocalReaction(
Map<String, dynamic>? current,
String emoji,
) {
final counters = <String, int>{};
final order = <String>[];
final rawCounters = current?['counters'];
if (rawCounters is List) {
for (final c in rawCounters) {
if (c is! Map) continue;
final r = c['reaction']?.toString();
if (r == null || r.isEmpty) continue;
final n = c['count'];
counters[r] = n is int ? n : 0;
order.add(r);
}
}
void decrement(String key) {
final next = (counters[key] ?? 1) - 1;
if (next <= 0) {
counters.remove(key);
order.remove(key);
} else {
counters[key] = next;
}
}
final prev = current?['yourReaction']?.toString();
String? your;
if (prev != null &&
EmojiKeywordIndex.normalize(prev) ==
EmojiKeywordIndex.normalize(emoji)) {
decrement(prev);
your = null;
} else {
if (prev != null && prev.isNotEmpty) decrement(prev);
if (!counters.containsKey(emoji)) order.add(emoji);
counters[emoji] = (counters[emoji] ?? 0) + 1;
your = emoji;
}
if (counters.isEmpty) return null;
final total = counters.values.fold<int>(0, (a, b) => a + b);
return {
'counters': [
for (final key in order) {'reaction': key, 'count': counters[key]},
],
'yourReaction': ?your,
'totalCount': total,
};
}
void _pruneReactionNotifiers() {
final liveIds = _messages.map((m) => m.id).toSet();
final dead = _reactionNotifiers.keys
.where((id) => !liveIds.contains(id))
.toList();
for (final id in dead) {
_reactionNotifiers.remove(id)?.dispose();
}
_messageKeys.removeWhere((id, _) => !liveIds.contains(id));
}
int _otherStatus = 0;
int? _otherSeenTime;
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
final ValueNotifier<ForwardRequest?> _pendingForward = ValueNotifier(null);
static const bool _crossChatReplySupported = false;
int? _replySourceChatId;
ForwardRequest? _forwardRequest;
bool _forwardSending = false;
final ValueNotifier<String?> _highlightMessageId = ValueNotifier(null);
Timer? _highlightTimer;
final ValueNotifier<double?> _jumpCacheExtent = ValueNotifier<double?>(null);
Timer? _goToMessageSettleTimer;
static const double _jumpCacheExtentPx = 800.0;
late final RouteSettle _routeSettle = RouteSettle(isMounted: () => mounted);
late final ChatSearchController _search;
late final AnimationController _searchAnim;
final FocusNode _searchFocusNode = FocusNode();
late final ChatPrankController _prank = ChatPrankController(
vsync: this,
contextOf: () => context,
isMounted: () => mounted,
onChanged: () {
if (mounted) setState(() {});
},
);
final ValueNotifier<String> _headerStatusNotifier = ValueNotifier('');
final ValueNotifier<int> _otherReadTime = ValueNotifier(0);
int _tempIdCounter = 0;
bool _textSending = false;
void _reserveKeyboardSpace(double height) {
setState(() => _keyboardReserve = height);
}
Future<void> _refreshChatAfterSend(int accountId) async {
if (chat != null || _commentsMode) return;
try {
final list = await chats.refreshChats(api, [widget.chatId]);
if (!mounted || list.isEmpty || _myId != accountId) return;
setState(() => chat = list.first);
_bumpMessages();
_syncOtherReadTime();
} catch (error) {
logger.w('Chat refresh after send failed: $error');
}
}
late final AnimationController _attachAnim;
late final CommandPanelController _commandPanel;
late final MentionPanelController _mentionPanel;
String _nextTempId() =>
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
late AnimationController _shimmerController;
Timer? _shimmerStartTimer;
bool _previewChat = false;
bool _subscribing = false;
String? _channelLink;
final ChatController _chatController = ChatController();
final LocationAttachmentController _locationAttachment =
LocationAttachmentController();
List<CachedMessage> get _messages => _chatController.messages;
set _messages(List<CachedMessage> v) => _chatController.messages = v;
ValueNotifier<int> get _messagesRev => _chatController.messagesRev;
bool get _historyKickedOff => _chatController.historyKickedOff;
set _historyKickedOff(bool v) => _chatController.historyKickedOff = v;
final GlobalKey _messageListKey = GlobalKey();
Widget? _messageListWidget;
final Set<String> _deletingIds = {};
static const double _avgMessageHeight = 72.0;
static const double _historyPrefetchExtent = _avgMessageHeight * 8;
static const double _scrollDownRevealExtent = _avgMessageHeight * 30;
static const double _scrollDownRevealFactor = 0.6;
static const double _scrollDownTeleportFactor = 2.0;
static const double _glossyHeaderHeight = 76.0;
static const double _glossySearchHeight = 58.0;
static const double _pinnedBannerLift = 6.0;
static const double _edgeFadeHeight = 24.0;
static const double _unreadSeparatorHeight = 30.0;
static const double _unreadSeparatorInset = 72.0;
static const double _unreadAnchorFallbackAlignment = 0.3;
static const int _jumpStallLimit = 8;
static const int _jumpFrameLimit = 240;
static const double _jumpStepMaxScreens = 4.0;
final BackdropKey _barBackdrop = BackdropKey();
final BackdropKey _pillBackdrop = BackdropKey();
bool get _isLoadingMore => _chatController.isLoadingMore;
set _isLoadingMore(bool v) => _chatController.isLoadingMore = v;
bool get _hasMoreHistory => _chatController.hasMoreHistory;
set _hasMoreHistory(bool v) => _chatController.hasMoreHistory = v;
List<Object>? _combinedItemsCache;
int? _combinedItemsKey;
bool _floatingDateScheduled = false;
int get _myId => _chatController.myId;
set _myId(int v) => _chatController.myId = v;
CachedChat? chat;
bool _peerIsBot = false;
bool _peerKindKnown = false;
bool _greetingMounted = false;
bool _botStartRequested = false;
bool _botStartPressed = false;
ChatWallpaper? _wallpaper;
bool get _composerFrosted =>
AppComposerBackground.current.value != ComposerBackground.standard;
bool get _composerUnderlap =>
AppChatChrome.current.value != ChatChromeStyle.color || _composerFrosted;
bool get _materialComposer =>
!ComposerChrome.isGlossy(AppComposerStyle.current.value);
bool get _composerPaintsSurface {
if (!_commentsMode &&
widget.chatType == 'CHANNEL' &&
_pendingForward.value == null) {
return false;
}
return _materialComposer && !_composerFrosted;
}
bool get _liquidChrome =>
AppVisualStyle.current.value.glossyChrome &&
ChatChromeMaterial.isLiquid(AppChatChrome.current.value);
ChatChromeStyle get _effectiveChrome {
final chrome = AppChatChrome.current.value;
if (chrome == ChatChromeStyle.liquidGlass) {
return ChatChromeStyle.transparent;
}
return chrome;
}
bool get _chromeVignette =>
_effectiveChrome == ChatChromeStyle.none && _wallpaper == null;
final ValueNotifier<double> _composerHeight = ValueNotifier(96);
final ValueNotifier<double> _pinnedBannerHeight = ValueNotifier(0);
final ValueNotifier<DateTime?> _floatingDate = ValueNotifier(null);
Timer? _floatingDateTimer;
late final AnimationController _floatingDateAnimController;
late final CurvedAnimation _floatingDateCurved;
late final AnimationController _scrollDownAnimController;
late final CurvedAnimation _scrollDownCurved;
bool _scrollDownVisible = false;
final ValueNotifier<int> _newMessageCount = ValueNotifier(0);
final ValueNotifier<int> _unreadMessageCount = ValueNotifier(0);
bool _clearCountScheduled = false;
final Set<String> _deferredIds = <String>{};
int _listEpoch = 0;
String? _scrollAnchorId;
final List<({String id, double pixels, double alignment})> _returnStack = [];
bool _returningToAnchor = false;
final Map<int, GlobalKey> _separatorKeys = {};
String? _lastSentId;
final ValueNotifier<int> _otherUnread = ValueNotifier(0);
final ValueNotifier<bool> _animojiHold = ValueNotifier(true);
final ValueNotifier<Set<String>> _selectedIds = ValueNotifier(const {});
final ValueNotifier<Offset?> _textSelectionDrag = ValueNotifier(null);
final ValueNotifier<({String id, Offset pos})?> _textSelection =
ValueNotifier(null);
late final AnimationController _selectionAnim;
bool get _selectionMode => _selectedIds.value.isNotEmpty;
void _prewarmQuickReactions() {
if (!mounted || !TlottieEngine.instance.available) return;
final dpr = (MediaQuery.maybeOf(context)?.devicePixelRatio ?? 2.0).clamp(
1.0,
2.0,
);
final px = ((44.0 * dpr).clamp(96.0, 512.0) / 32).ceil() * 32;
for (final a in animojiModule.quickAnimojis) {
for (final url in [a.lottieUrl, a.lottiePlayUrl]) {
if (url != null && url.isNotEmpty) {
unawaited(TlottieEngine.instance.prewarm(url, px));
}
}
}
}
@override
void initState() {
super.initState();
_previewChat = widget.channelSubscribed == false;
_chatController.chatId = widget.chatId;
_chatController.isMounted = () => mounted;
if (!_commentsMode && !widget.preview) ChatScreen._open.add(this);
if (!widget.preview) {
unawaited(PushService.clearChatNotification(widget.chatId));
}
if (!_commentsMode && !widget.preview) {
unawaited(NotificationBridge.instance.pushActiveChat(widget.chatId));
}
unawaited(
animojiModule
.ensureLoaded()
.then((_) {
_prewarmQuickReactions();
if (mounted) _bumpMessages();
})
.catchError((_) {}),
);
WidgetsBinding.instance.addObserver(this);
_uploadEventSub = UploadService.instance.events.listen(_onUploadEvent);
_syncUploadStatus();
chats.chatsChanged.addListener(_onChatsBump);
_messageController.addListener(_onTextChanged);
_scrollController.addListener(_onScrollForDate);
_scrollController.addListener(_maybeLoadMoreHistory);
_scrollController.addListener(_recordScrollPixels);
_scrollController.addListener(_scheduleReadMarker);
_scrollController.addListener(_updateScrollDownVisible);
if (!widget.preview) MediaPlayback.instance.enterChat(widget.chatId);
AppVisualStyle.current.addListener(_onVisualStyleChanged);
AppChatChrome.current.addListener(_onVisualStyleChanged);
AppComposerStyle.current.addListener(_onVisualStyleChanged);
AppComposerBackground.current.addListener(_onVisualStyleChanged);
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_attachAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 320),
reverseDuration: const Duration(milliseconds: 240),
);
_stickers = StickerPanelController(
vsync: this,
onSendTyping: () => messagesModule.sendTyping(widget.chatId, 'STICKER'),
);
_showAttachmentPanel.addListener(_onAttachPanelToggle);
_commandPanel = CommandPanelController(
vsync: this,
textOf: () => _messageController.text,
onSelected: _onCommandSelected,
);
_mentionPanel = MentionPanelController(
vsync: this,
chatId: widget.chatId,
enabled: _mentionsAvailable,
selfId: () => _myId,
valueOf: () => _messageController.value,
onSelected: _onMentionSelected,
);
_selectionAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
reverseDuration: const Duration(milliseconds: 200),
);
_searchAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 280),
reverseDuration: const Duration(milliseconds: 220),
);
_search = ChatSearchController(
chatId: widget.chatId,
isMounted: () => mounted,
);
final incomingReply = widget.replyRequest;
if (incomingReply != null) {
_replyTo.value = incomingReply.message;
_replySourceChatId = incomingReply.sourceChatId == widget.chatId
? null
: incomingReply.sourceChatId;
}
final incomingForward = widget.forwardRequest;
if (incomingForward != null) {
_forwardRequest = incomingForward;
_pendingForward.value = incomingForward;
}
_pushSub = api.pushStream
.where(
(p) =>
p.opcode == Opcode.notifMark ||
p.opcode == Opcode.notifTyping ||
p.opcode == Opcode.notifMsgDelayed,
)
.listen(_onIncomingPush);
_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 {
// #***! тип из маршрута бывает запасным (CHAT/DIALOG), а канал
// #***! узнаём позже из chat; счётчики слушаем в любом чате
_commentsInfoSub = commentsModule.infoStream.listen(_onCommentsInfo);
}
ChatActivityStore.instance
.listenable(widget.chatId)
.addListener(_recomputeHeaderStatus);
ChatMembersStore.instance
.listenable(widget.chatId)
.addListener(_recomputeHeaderStatus);
_connSub = api.stateStream.listen((state) {
if (!mounted) return;
_recomputeHeaderStatus();
if (state == SessionState.online) _requestCommentCounts();
});
debugForceOffline.addListener(_recomputeHeaderStatus);
PresenceFetch.revision.addListener(_onPresenceChanged);
ContactsModule.revision.addListener(_onContactsChanged);
_floatingDateAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
reverseDuration: const Duration(milliseconds: 380),
);
_floatingDateCurved = CurvedAnimation(
parent: _floatingDateAnimController,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
);
_scrollDownAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
reverseDuration: const Duration(milliseconds: 180),
);
_scrollDownCurved = CurvedAnimation(
parent: _scrollDownAnimController,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
);
unawaited(_fastPreloadCache());
unawaited(_loadParticipantsCount());
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
}
@override
void reloadAfterReconnect() {
if (!_historyKickedOff) return;
unawaited(_loadHistory());
unawaited(_loadParticipantsCount());
}
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;
if (widget.chatType == 'CHANNEL') {
final link = info?['link'];
if (link is String && link.isNotEmpty) _channelLink = link;
}
}
Future<void> _loadPeerKind() async {
if (widget.chatType != 'DIALOG' || _myId == 0) return;
final peerId = widget.chatId ^ _myId;
if (peerId <= 0) return;
final cached = ContactInfoFetch.peek(peerId);
if (cached != null) _applyPeerInfo(peerId, cached);
final info = await ContactInfoFetch.get(peerId);
if (info != null) _applyPeerInfo(peerId, info);
if ((info ?? cached)?.isBot ?? false) {
unawaited(BotInfoFetch.get(peerId));
}
}
void _applyPeerInfo(int peerId, ContactInfo info) {
if (!mounted) return;
final avatar = info.avatarUrl;
final avatarIsNew =
avatar != null &&
avatar.isNotEmpty &&
ContactCache.getAvatar(peerId) != avatar;
if (avatarIsNew) ContactCache.putAvatar(peerId, avatar);
if (_peerKindKnown && _peerIsBot == info.isBot && !avatarIsNew) return;
setState(() {
_peerKindKnown = true;
_peerIsBot = info.isBot;
});
}
Future<void> _fastPreloadCache() async {
final p = await AppDatabase.loadActiveProfile();
if (!mounted) return;
_myId = p?.id ?? 0;
if (p != null && p.id != 0) {
final myName = [
p.firstName,
p.lastName,
].whereType<String>().where((s) => s.isNotEmpty).join(' ');
if (myName.isNotEmpty) ContactCache.put(p.id, myName);
ContactCache.putAvatar(p.id, p.baseUrl);
}
if (_commentsMode) return;
_restoreDraft();
unawaited(_loadPeerKind());
unawaited(_loadWallpaper());
unawaited(_loadReactionSettings());
_forwardedSub = ForwardSender.delivered.listen(_onForwardedHere);
_departedSub = chats.departedChats.listen((id) {
if (!mounted || id != widget.chatId) return;
if (widget.embedded) {
widget.onClose?.call();
return;
}
final route = ModalRoute.of(context);
if (route == null || !route.isActive) return;
final navigator = Navigator.of(context);
if (route.isCurrent) {
navigator.pop();
} else {
navigator.removeRoute(route);
}
});
unawaited(_loadEncryption());
unawaited(_refreshBadge());
try {
final chatRows = await chats.getChat(_myId, widget.chatId);
if (!mounted) return;
if (widget.chatType == 'CHANNEL') {
final preview = !await AppDatabase.isChatInList(_myId, widget.chatId);
if (!mounted) return;
if (preview != _previewChat) setState(() => _previewChat = preview);
}
if (chatRows.isNotEmpty) {
setState(() {
chat = chatRows.first;
});
unawaited(_loadMyRights());
_bumpMessages();
_seedPresenceFromChat();
_recomputeHeaderStatus();
_syncOtherReadTime();
}
} catch (_) {}
_resolveUnreadAnchor();
final cached = MessageSessionCache.get(_myId, widget.chatId);
if (cached != null && cached.messages.isNotEmpty) {
setState(() {
_messages = List<CachedMessage>.of(cached.messages);
_deferredIds.clear();
_hasMoreHistory = !cached.reachedStart;
_messagesRev.value++;
});
_mergePendingMedia();
_syncReactionNotifiersFromMessages();
_requestCommentCounts();
_revealOrHoldInitial();
return;
}
final firstRows = await AppDatabase.loadMessages(
_myId,
widget.chatId,
limit: 20,
onlyVisible: !QlyraSettings.viewDeleted.value,
);
final ranges = await AppDatabase.loadMessageRanges(_myId, widget.chatId);
if (!mounted) return;
if (firstRows.isNotEmpty) {
final decoded = firstRows.reversed
.map((r) => CachedMessage.fromDbRow(r))
.toList();
final first = ranges.isEmpty
? decoded
: ranges.clipLatest(decoded, (m) => m.time);
setState(() {
_messages = first;
_deferredIds.clear();
_messagesRev.value++;
});
_mergePendingMedia();
_requestCommentCounts();
_revealOrHoldInitial();
}
}
void _resolveUnreadAnchor() {
final c = chat;
_readMarkTime = c?.participants[_myId] ?? 0;
if (c == null || c.unreadCount <= 0) {
_unreadAnchorTime = null;
} else {
final myMark = c.participants[_myId] ?? 0;
_unreadAnchorTime = myMark > 0 ? myMark : null;
}
_awaitingPosition = c != null && c.unreadCount > 0;
_syncUnreadMessageCount();
}
void _resolveCountBasedAnchor() {
final c = chat;
if (c == null || c.unreadCount <= 0 || _messages.isEmpty) return;
final unread = c.unreadCount;
if (_messages.length > unread) {
_unreadAnchorTime = _messages[_messages.length - unread - 1].time;
} else if (!_hasMoreHistory) {
_unreadAnchorTime = _messages.first.time - 1;
}
}
void _revealOrHoldInitial() {
if (_awaitingPosition && !_canPositionNow()) return;
setState(() {
_isLoading = false;
_onLoadingFinished();
});
}
bool _canPositionNow() {
if (_unreadAnchorTime == null) _resolveCountBasedAnchor();
final ua = _unreadAnchorTime;
if (ua == null) return false;
final firstUnread = _messages.indexWhere((m) => m.time > ua);
if (firstUnread == -1) return _newestMessageLoaded();
return firstUnread > 0 || !_hasMoreHistory;
}
bool _newestMessageLoaded() {
if (_messages.isEmpty) return false;
final serverLast = chat?.lastMsgTime ?? 0;
return _messages.last.time >= serverLast;
}
void _onFirstFrameRendered(Duration _) {
if (!mounted) return;
if (widget.embedded) {
_routeSettle.settleNow();
} else {
_routeSettle.bind(context);
}
_routeSettle.run(_kickoffHistory);
}
void _kickoffHistory() {
_animojiHold.value = false;
if (_historyKickedOff) return;
_historyKickedOff = true;
_shimmerStartTimer = Timer(const Duration(milliseconds: 150), () {
if (!mounted || !_isLoading) return;
_shimmerController.repeat();
});
unawaited(_loadHistory().then((_) => _sendPendingBotStart()));
}
bool get _isRouteCurrent {
if (!mounted) return false;
final route = ModalRoute.of(context);
return route == null || route.isCurrent;
}
Future<void> _sendPendingBotStart() async {
final payload = widget.botStartPayload;
if (payload == null || _botStartRequested || !mounted) return;
_botStartRequested = true;
await _sendBotStart(payload);
}
Future<bool> _sendBotStart([String? startPayload]) async {
if (_myId == 0) {
final profile = await AppDatabase.loadActiveProfile();
if (!mounted) return false;
_myId = profile?.id ?? 0;
}
try {
final sent = await messagesModule.sendBotStart(
widget.chatId,
startPayload,
);
if (!mounted) return false;
if (sent == null) {
showCustomNotification(
context,
AppLocalizations.of(context)!.chatScreenBotStartFailed,
);
return false;
}
await _persistOutgoing(
CachedMessage.fromPushPayload(_myId, widget.chatId, sent),
);
return true;
} catch (_) {
if (mounted) {
showCustomNotification(
context,
AppLocalizations.of(context)!.chatScreenBotStartFailed,
);
}
return false;
}
}
bool get _botStartDue =>
_peerIsBot &&
_isPersonDialog &&
!_isLoading &&
!_botStartPressed &&
_messages.isEmpty;
Future<void> _startBot() async {
if (_botStartPressed) return;
_botStartPressed = true;
_bumpMessages();
if (await _sendBotStart() || !mounted) return;
_botStartPressed = false;
_bumpMessages();
}
void _onLoadingFinished() {
_shimmerStartTimer?.cancel();
_shimmerStartTimer = null;
_applyInitialPositioning();
}
void _recordScrollPixels() {
if (!_scrollController.hasClients) return;
if (_initialPositionDone &&
_scrollController.position.userScrollDirection !=
ScrollDirection.idle) {
_userDidScroll = true;
_pinnedMessageId = null;
}
}
double _unreadAnchorAlignment() {
final listBox = _listKey.currentContext?.findRenderObject();
if (listBox is! RenderBox || listBox.size.height <= 0) {
return _unreadAnchorFallbackAlignment;
}
final separator =
_unreadSeparatorKey.currentContext?.size?.height ??
_unreadSeparatorHeight;
final glossy = AppVisualStyle.current.value.glossyChrome;
final chromeBottom = _effectiveChrome == ChatChromeStyle.color
? 0.0
: MediaQuery.paddingOf(context).top +
(glossy ? _glossyHeaderHeight : kToolbarHeight) +
_pinnedBannerHeight.value;
final desiredTop = chromeBottom + separator + _unreadSeparatorInset;
return (desiredTop / listBox.size.height).clamp(0.0, 0.5);
}
void _positionToMessage(String messageId) {
_pinnedMessageId = messageId;
_jumpCacheExtent.value = _jumpCacheExtentPx;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_pinnedAlignment = _unreadAnchorAlignment();
_scrollToLoadedMessage(
messageId,
alignment: _pinnedAlignment,
highlight: false,
notifyIfMissing: false,
onSettled: () {
if (!mounted) return;
setState(_markPositioned);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_jumpCacheExtent.value = null;
_reapplyPinIfNeeded();
});
},
);
});
}
void _reapplyPinIfNeeded() {
final id = _pinnedMessageId;
if (id == null || _userDidScroll || !_scrollController.hasClients) return;
_holdReadMarker();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _pinnedMessageId != id || _userDidScroll) {
_releaseReadMarker();
return;
}
_alignLoadedMessage(
id,
_pinnedAlignment,
0,
onSettled: _releaseReadMarker,
);
});
}
void _applyInitialPositioning() {
if (_initialPositionDone) {
if (_shimmerController.isAnimating) _shimmerController.stop();
_scheduleReadMarker();
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;
}
final c = chat;
if (c != null && c.unreadCount > 0) {
if (_unreadAnchorTime == null) _resolveCountBasedAnchor();
final ua = _unreadAnchorTime;
if (ua == null) {
if (_hasMoreHistory) {
_positioningInFlight = true;
unawaited(_loadUntilUnreadReady());
} else {
_markPositioned();
}
return;
}
final firstUnread = _messages.indexWhere((m) => m.time > ua);
if (firstUnread == -1) {
_markPositioned();
return;
}
if (firstUnread > 0 || !_hasMoreHistory) {
_initialPositionDone = true;
_positionToMessage(_messages[firstUnread].id);
} else {
_positioningInFlight = true;
unawaited(_loadUntilUnreadReady());
}
return;
}
_markPositioned();
}
void _markPositioned() {
_positioningInFlight = false;
_initialPositionDone = true;
_awaitingPosition = false;
_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 _openChatInfo({ChatInfoTab? initialTab}) {
final navigator = Navigator.of(context);
final chatRoute = ModalRoute.of(context);
navigator.push(
MaterialPageRoute(
builder: (_) => ChatInfoScreen(
chatId: widget.chatId,
name: _headerName(),
imageUrl: _headerAvatarUrl(),
chatType: widget.chatType,
heroTag: _profileHeroTag,
initialTab: initialTab,
openedFromChat: true,
onJumpToMessage: (chatRoute == null || widget.embedded)
? null
: (messageId, time) {
navigator.popUntil((r) => r == chatRoute);
_requestGoToMessage(messageId, time);
},
),
),
);
}
late final PhotoViewerActions _photoActions = PhotoViewerActions(
goToMessage: _requestGoToMessage,
forward: _forwardMessageById,
delete: (messageId, senderId) =>
_confirmDeleteMessage(messageId, senderId == _myId),
viewAllMedia: () => _openChatInfo(initialTab: ChatInfoTab.media),
);
void _forwardMessageById(String messageId) {
final message = _messages.where((m) => m.id == messageId).firstOrNull;
if (message == null) {
showCustomNotification(
context,
AppLocalizations.of(context)!.chatScreenMessageNotLoaded,
);
return;
}
unawaited(_forwardMessages([message]));
}
void _requestGoToMessage(String id, int time) {
if (!mounted) return;
setState(_beginTargetNavigation);
_goToMessageSettleTimer = Timer(const Duration(milliseconds: 340), () {
if (mounted) unawaited(_runGoToMessage(id, time));
});
}
Future<void> _loadUntilUnreadReady() async {
await _walkHistoryBack(
reached: () {
if (_unreadAnchorTime == null) _resolveCountBasedAnchor();
final ua = _unreadAnchorTime;
return ua != null && _messages.indexWhere((m) => m.time > ua) > 0;
},
maxPages: 15,
);
if (!mounted) return;
if (_unreadAnchorTime == null) _resolveCountBasedAnchor();
final ua = _unreadAnchorTime;
final idx = ua == null ? -1 : _messages.indexWhere((m) => m.time > ua);
_positioningInFlight = false;
_initialPositionDone = true;
if (idx >= 0) {
_positionToMessage(_messages[idx].id);
} else {
setState(_markPositioned);
}
}
void _scheduleReadMarker() => _readMarker.schedule();
void _holdReadMarker() => _readMarker.hold();
void _releaseReadMarker() => _readMarker.release();
void _updateReadMarker() {
if (_commentsMode || widget.preview) return;
if (!mounted || _myId == 0 || _messages.isEmpty) return;
if (_awaitingPosition || !_initialPositionDone) return;
if (_readMarker.held) return;
if (!_scrollController.hasClients) return;
final listBox = _listKey.currentContext?.findRenderObject();
if (listBox is! RenderBox) return;
final viewportBottom = listBox.size.height;
if (viewportBottom <= 0) return;
CachedMessage? candidate;
int topIndex = -1;
for (int i = _messages.length - 1; i >= 0; i--) {
final m = _messages[i];
final ctx = _messageKeys[m.id]?.currentContext;
if (ctx == null) continue;
final box = ctx.findRenderObject();
if (box is! RenderBox || !box.attached) continue;
final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy;
final bottom = top + box.size.height;
if (bottom <= 0 || top >= viewportBottom) continue;
candidate ??= m;
topIndex = i;
}
if (candidate == null) return;
final atBottom = candidate.id == _messages.last.id;
if (_unreadAnchorTime != null &&
_userDidScroll &&
_unreadSeparatorScrolledPast(
atBottom,
topIndex,
listBox,
viewportBottom,
)) {
_unreadAnchorTime = null;
_bumpMessages();
}
if (candidate.time <= _readMarkTime) return;
_readMarkTime = candidate.time;
_syncUnreadMessageCount();
final remaining = _unreadMessageCount.value;
unawaited(
chats.markReadUpTo(
api,
_myId,
widget.chatId,
candidate.id,
candidate.time,
remaining: remaining,
),
);
}
bool _unreadSeparatorScrolledPast(
bool atBottom,
int topIndex,
RenderBox listBox,
double viewportBottom,
) {
if (atBottom) return true;
final ua = _unreadAnchorTime;
if (ua == null) return false;
final firstUnread = _messages.indexWhere((m) => m.time > ua);
if (firstUnread == -1) return true;
if (topIndex >= 0 && topIndex > firstUnread) return true;
final box = _messageKeys[_messages[firstUnread].id]?.currentContext
?.findRenderObject();
if (box is RenderBox && box.attached) {
final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy;
if (top <= 0) return true;
}
return false;
}
Future<void> _markMessageUnread(CachedMessage message) async {
final unread = await chats.markUnread(
api,
_myId,
widget.chatId,
message.time,
);
if (!mounted) return;
if (unread == null) {
showCustomNotification(
context,
AppLocalizations.of(context)!.chatScreenMarkUnreadFailed,
);
return;
}
Navigator.of(context).pop();
}
bool _canShowReadBy(CachedMessage message) {
if (message.isControl || message.deleted) return false;
if (int.tryParse(message.id) == null) return false;
final type = chat?.type ?? widget.chatType;
return type == 'CHAT' || type == 'GROUP';
}
Future<List<MessageReader>> _loadReadBy(CachedMessage message) async {
final marks = await chats.getReadMarks(api, _myId, widget.chatId);
final reactions = await messagesModule.getDetailedReactions(
widget.chatId,
message.id,
);
final readerIds = <int>{
...marks.entries.where((e) => e.value >= message.time).map((e) => e.key),
...reactions.keys,
}..removeAll({_myId, message.senderId});
if (readerIds.isEmpty || !mounted) return const [];
await messagesModule.ensureContactNames(readerIds);
await animojiModule.ensureLoaded();
if (!mounted) return const [];
final animojiByEmoji = {
for (final animoji in animojiModule.animojis)
EmojiKeywordIndex.normalize(animoji.emoji): animoji,
};
final unknownName = AppLocalizations.of(
context,
)!.msgActionsReadByUnknownUser;
final readers = readerIds.map((id) {
final emoji = reactions[id];
final animoji = emoji == null
? null
: animojiByEmoji[EmojiKeywordIndex.normalize(emoji)];
final name = ContactCache.get(id);
return MessageReader(
id: id,
name: name == null || name.isEmpty ? unknownName : name,
avatarUrl: ContactCache.getAvatar(id),
reaction: emoji == null
? null
: ReactionEmoji(
emoji: emoji,
animationUrl: animoji?.lottieUrl,
staticUrl: animoji?.iconUrl,
),
);
}).toList();
readers.sort((a, b) {
final aReacted = a.reaction != null;
final bReacted = b.reaction != null;
if (aReacted != bReacted) return aReacted ? -1 : 1;
return (marks[b.id] ?? 0).compareTo(marks[a.id] ?? 0);
});
return readers;
}
bool _canLinkMessage(CachedMessage message) {
if (_commentsMode || message.isControl) return false;
final type = chat?.type ?? widget.chatType;
if (type != 'CHAT' && type != 'GROUP' && type != 'CHANNEL') return false;
return MessageLinkToken.encode(message.id) != null;
}
Future<void> _copyMessageLink(CachedMessage message) async {
final url = MessageLinkToken.messageUrl(
chatId: widget.chatId,
messageId: message.id,
publicLink: chat?.publicLink,
);
if (url == null) return;
await Clipboard.setData(ClipboardData(text: url));
if (!mounted) return;
showCustomNotification(
context,
AppLocalizations.of(context)!.sharedLinkCopied,
);
}
bool _canPinMessage(CachedMessage message) {
if (message.isControl) return false;
if (int.tryParse(message.id) == null) return false;
return _canPin;
}
AdminRights? _myRights;
Future<bool> _confirmSend() async {
final accountId = _myId;
if (chat?.confirmBeforeSend ?? false) {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showConfirmDialog(
context,
message: l10n.chatSendConfirmMessage,
confirmLabel: l10n.chatSendConfirmAction,
);
if (!confirmed || !mounted || _myId != accountId) return false;
}
if (!_commentsMode && widget.chatType == 'DIALOG' && accountId != 0) {
try {
await AppDatabase.ensureDialogCached(
accountId,
widget.chatId,
title: _chatName,
iconUrl: _chatImageUrl,
);
} catch (error, stack) {
logger.w(
'Outgoing dialog could not be cached: $error',
stackTrace: stack,
);
if (mounted) {
showCustomNotification(
context,
'Не удалось сохранить сообщение. Повторите отправку',
);
}
return false;
}
}
return mounted && _myId == accountId;
}
Future<void> _loadMyRights() async {
final current = chat;
if (current == null ||
current.owner == _myId ||
!current.admins.contains(_myId)) {
_myRights = null;
return;
}
final info = await ChatInfoFetch.get(widget.chatId);
if (!mounted || info == null) return;
setState(() => _myRights = info.rightsOf(_myId));
}
bool _hasRight(AdminRight right) {
final current = chat;
if (current == null) return false;
if (current.owner == _myId) return true;
if (!current.admins.contains(_myId)) return false;
return _myRights?.has(right) ?? true;
}
bool get _isChannel => (chat?.type ?? widget.chatType) == 'CHANNEL';
bool get _canPostToChannel => _hasRight(AdminRight.createPosts);
bool get _canDeleteOthers => _hasRight(
_isChannel ? AdminRight.deletePosts : AdminRight.deleteMessages,
);
bool get _everyoneCanPin =>
!_isChannel && (chat?.options.contains('ALL_CAN_PIN_MESSAGE') ?? false);
bool get _canPin =>
widget.chatType != 'DIALOG' &&
(_everyoneCanPin || _hasRight(AdminRight.pinMessages));
bool get _commentsEnabled => chat?.commentsEnabled ?? false;
Future<void> _togglePinMessage(CachedMessage message) async {
final messageId = int.tryParse(message.id);
if (messageId == null) return;
if (chat?.pinnedMsgId == messageId) {
await _unpinCurrentMessage();
return;
}
final previousChat = chat;
{
final preview = _pinnedPreviewFor(message);
_applyPinnedMessageLocally(
messageId: messageId,
text: preview.text,
time: message.time,
isPreview: preview.isPreview,
);
}
final error = await chats.setPinnedMessage(
api,
chatId: widget.chatId,
messageId: messageId,
notify: true,
);
if (!mounted) return;
if (error != null) {
if (previousChat != null) setState(() => chat = previousChat);
showCustomNotification(context, error);
return;
}
showCustomNotification(
context,
AppLocalizations.of(context)!.chatScreenMessagePinned,
);
}
Future<void> _unpinCurrentMessage() async {
final accountId = _myId;
final chatId = widget.chatId;
final pinnedId = chat?.pinnedMsgId;
if (pinnedId == null) return;
final overlay = Overlay.of(context, rootOverlay: true);
showUndoNotification(
context,
'Открепление сообщения через 5 секунд',
onUndo: () {},
onCommit: () async {
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
final current = await chats.getChat(accountId, chatId);
if (current.isEmpty || current.first.pinnedMsgId != pinnedId) return;
final error = await chats.setPinnedMessage(
api,
chatId: chatId,
messageId: null,
notify: false,
);
if (error != null && overlay.mounted) {
showCustomNotificationOnOverlay(overlay, error);
} else if (mounted) {
_applyPinnedMessageLocally();
}
},
);
}
({String? text, bool isPreview}) _pinnedPreviewFor(CachedMessage message) =>
pinnedMessagePreview(message.previewPayload);
void _applyPinnedMessageLocally({
int? messageId,
String? text,
int? time,
bool isPreview = false,
}) {
final current = chat;
if (current == null) return;
setState(() {
chat = current.copyWith(
pinnedMsgId: messageId,
pinnedMsgText: text,
pinnedMsgTime: time,
pinnedMsgIsPreview: isPreview,
);
});
}
void _jumpToPinnedMessage() {
final id = chat?.pinnedMsgId;
if (id == null) return;
final messageId = id.toString();
if (_messages.any((m) => m.id == messageId)) {
_scrollToLoadedMessage(messageId);
return;
}
setState(_beginTargetNavigation);
unawaited(_runGoToMessage(messageId, chat?.pinnedMsgTime ?? 0));
}
bool _badgeRefreshing = false;
bool _badgeRefreshQueued = false;
void _onChatsBump() {
unawaited(_reloadChatMeta());
if (_badgeRefreshing) {
_badgeRefreshQueued = true;
return;
}
unawaited(_runBadgeRefresh());
}
Future<void> _reloadChatMeta() async {
if (_myId == 0) return;
final rows = await chats.getChat(_myId, widget.chatId);
if (!mounted || rows.isEmpty) return;
final fresh = rows.first;
final current = chat;
_syncUnreadMessageCount(snapshot: fresh);
if (current != null &&
current.unreadCount == fresh.unreadCount &&
mapEquals(current.participants, fresh.participants) &&
current.lastMsgTime == fresh.lastMsgTime &&
current.pinnedMsgId == fresh.pinnedMsgId &&
current.pinnedMsgText == fresh.pinnedMsgText &&
current.pinnedMsgTime == fresh.pinnedMsgTime &&
current.pinnedMsgIsPreview == fresh.pinnedMsgIsPreview &&
current.owner == fresh.owner &&
current.options.length == fresh.options.length &&
current.options.containsAll(fresh.options) &&
current.admins.length == fresh.admins.length &&
current.admins.containsAll(fresh.admins) &&
current.activeCallData == fresh.activeCallData &&
current.publicLink == fresh.publicLink &&
current.title == fresh.title &&
current.iconUrl == fresh.iconUrl) {
return;
}
setState(() => chat = fresh);
unawaited(_loadMyRights());
_requestCommentCounts();
_syncOtherReadTime();
}
void _notePeerReadThrough(CachedMessage message) {
if (widget.chatType != 'DIALOG' || message.senderId == _myId) return;
final c = chat;
if (c == null) return;
if ((c.participants[message.senderId] ?? 0) >= message.time) return;
c.participants[message.senderId] = message.time;
_syncOtherReadTime();
}
bool get _showsCallBanner => !_commentsMode && chat?.activeCall != null;
Future<void> _joinChatCall() async {
final call = chat?.activeCall;
if (call == null) return;
await joinGroupCall(
context,
token: call.joinLink,
name: _headerName(),
isVideo: call.isVideo,
);
}
Future<void> _runBadgeRefresh() async {
_badgeRefreshing = true;
try {
await _refreshBadge();
} finally {
_badgeRefreshing = false;
if (_badgeRefreshQueued && mounted) {
_badgeRefreshQueued = false;
unawaited(_runBadgeRefresh());
}
}
}
Future<void> _refreshBadge() async {
if (_myId == 0) return;
final total = await AppDatabase.sumUnread(
_myId,
excludeChatId: widget.chatId,
excludeChatIds: ArchivedChatsStore.instance.archivedChatIds(_myId),
);
if (mounted) _otherUnread.value = total;
}
Future<void> _loadHistory() async {
if (_myId == 0) {
final activeProfile = await AppDatabase.loadActiveProfile();
if (!mounted) return;
_myId = activeProfile?.id ?? 0;
}
if (_commentsMode) {
await _loadCommentsHistory();
return;
}
if (widget.chatType == 'DIALOG') {
unawaited(_loadOtherPresence());
}
unawaited(_refreshScheduledCount());
await _chatController.loadRemainingHistory(
onApplyMerged: _applyMergedMessages,
onLoadingFinished: () {
setState(() {
_isLoading = false;
_onLoadingFinished();
});
},
onPreview: () => _previewChat = true,
onSenderNames: () {
_loadForwardedSenderNames();
_loadGroupSenderNames();
},
);
}
void _maybeLoadMoreHistory() {
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;
final pos = _scrollController.position;
if (pos.maxScrollExtent <= 0) return;
if (pos.maxScrollExtent - pos.pixels <= _historyPrefetchExtent) {
unawaited(_loadMoreHistory());
}
}
void _maybeFillGap() {
final controller = _chatController;
if (!controller.hasGap || controller.loadingGap) return;
final oldestRendered = _oldestRenderedMessageTime();
for (final gap in controller.gaps) {
if (!ChatController.gapFillLeavesViewportInPlace(gap, oldestRendered)) {
continue;
}
unawaited(_fillGapForward(gap));
return;
}
}
int? _oldestRenderedMessageTime() {
for (final message in _messages) {
final box = _messageKeys[message.id]?.currentContext?.findRenderObject();
if (box is RenderBox && box.attached) return message.time;
}
return null;
}
Future<void> _fillGapForward(HistoryGap gap) async {
String? anchorId;
double? anchorAt;
double? anchorAlignment;
final added = await _chatController.fillGapForward(
gap,
beforeApply: () {
final id = _viewportAnchorId();
anchorId = id;
if (id == null) return;
anchorAt = _messageContentOffset(id);
anchorAlignment = _messageAlignmentInList(id);
},
);
if (!mounted || added == 0) return;
_syncReactionNotifiersFromMessages();
_holdReadMarker();
_bumpMessages();
await WidgetsBinding.instance.endOfFrame;
if (!mounted) {
_releaseReadMarker();
return;
}
final id = anchorId;
final at = anchorAt;
final alignment = anchorAlignment;
if (id != null && at != null && !_restoreContentOffset(id, at)) {
_historyAutoloadSuppressCount++;
_alignLoadedMessage(
id,
alignment ?? 0,
0,
epoch: _userGestureEpoch,
onSettled: () {
_historyAutoloadSuppressCount--;
_releaseReadMarker();
},
);
} else {
_releaseReadMarker();
}
_loadForwardedSenderNames();
_loadGroupSenderNames();
}
int get _visibleMessageCount {
if (_deferredIds.isEmpty) return _messages.length;
var visible = _messages.length;
while (visible > 0 && _deferredIds.contains(_messages[visible - 1].id)) {
visible--;
}
return visible;
}
void _flushDeferredMessages() {
if (_deferredIds.isEmpty) return;
_deferredIds.clear();
_bumpMessages();
}
String? _viewportAnchorId() {
final listBox = _listKey.currentContext?.findRenderObject();
if (listBox is! RenderBox || !listBox.attached) return null;
final height = listBox.size.height;
String? newest;
for (final message in _messages) {
final box = _messageKeys[message.id]?.currentContext?.findRenderObject();
if (box is! RenderBox || !box.attached) continue;
final dy = box.localToGlobal(Offset.zero, ancestor: listBox).dy;
if (dy >= 0 && dy <= height) newest = message.id;
}
return newest;
}
double? _messageOffsetInList(String messageId) {
final listBox = _listKey.currentContext?.findRenderObject();
final box = _keyForMessage(messageId).currentContext?.findRenderObject();
if (listBox is! RenderBox || box is! RenderBox || !box.attached) {
return null;
}
return box.localToGlobal(Offset.zero, ancestor: listBox).dy;
}
double? _messageContentOffset(String messageId) {
if (!_scrollController.hasClients) return null;
final dy = _messageOffsetInList(messageId);
if (dy == null) return null;
return dy - _scrollController.position.pixels;
}
double? _messageAlignmentInList(String messageId) {
final listBox = _listKey.currentContext?.findRenderObject();
if (listBox is! RenderBox || listBox.size.height <= 0) return null;
final dy = _messageOffsetInList(messageId);
if (dy == null) return null;
return (dy / listBox.size.height).clamp(0.0, 1.0);
}
bool _restoreContentOffset(String messageId, double before) {
if (!_scrollController.hasClients) return false;
final after = _messageContentOffset(messageId);
if (after == null) return false;
final delta = before - after;
if (delta.abs() <= 0.5) return true;
final pos = _scrollController.position;
final target = (pos.pixels + delta).clamp(
pos.minScrollExtent,
pos.maxScrollExtent,
);
if ((target - pos.pixels).abs() <= 0.5) return true;
_scrollController.jumpTo(target);
return true;
}
Future<void> _loadMessageWindow(String messageId, int targetTime) async {
if (targetTime <= 0) {
await _walkHistoryBack(
reached: () => _messages.any((m) => m.id == messageId),
maxPages: 10,
);
return;
}
_historyAutoloadSuppressCount++;
try {
await _chatController.loadMessageWindow(
targetId: messageId,
targetTime: targetTime,
);
} finally {
_historyAutoloadSuppressCount--;
}
if (!mounted) return;
_syncReactionNotifiersFromMessages();
_bumpMessages();
_loadForwardedSenderNames();
_loadGroupSenderNames();
}
Future<void> _walkHistoryBack({
required bool Function() reached,
required int maxPages,
int targetTime = 0,
}) async {
if (reached()) return;
_historyAutoloadSuppressCount++;
try {
var page = 0;
while (mounted &&
page < maxPages &&
_hasMoreHistory &&
!reached() &&
(_messages.isEmpty || _messages.first.time > targetTime)) {
page++;
final before = _messages.isEmpty ? 0 : _messages.first.time;
await _loadMoreHistory(
resolveSenderNames: false,
pageSize: ChatController.historyWalkPageSize,
persist: false,
);
if (!mounted) return;
final after = _messages.isEmpty ? 0 : _messages.first.time;
if (after == before) break;
}
} finally {
_historyAutoloadSuppressCount--;
}
if (!mounted) return;
_chatController.persistSessionCache();
_loadForwardedSenderNames();
_loadGroupSenderNames();
}
Future<void> _loadMoreHistory({
bool resolveSenderNames = true,
int? pageSize,
bool persist = true,
}) async {
await _chatController.loadMoreHistory(
pageSize: pageSize,
persist: persist,
onLoadingStarted: _bumpMessages,
onLoaded: (added) {
if (added > 0) _syncReactionNotifiersFromMessages();
_bumpMessages();
if (resolveSenderNames) {
_loadForwardedSenderNames();
_loadGroupSenderNames();
}
},
onError: (_) {
if (mounted) {
_isLoadingMore = false;
_bumpMessages();
}
},
);
}
void _applyMergedMessages(
List<CachedMessage> decodedDesc, {
bool markLoaded = false,
}) {
final anchor = _captureViewportAnchor();
final changed = _chatController.mergeMessages(decodedDesc);
_requestCommentCounts();
if (!changed && !markLoaded) return;
setState(() {
if (markLoaded) {
_isLoading = false;
_onLoadingFinished();
}
});
if (changed) {
_syncReactionNotifiersFromMessages();
_pruneReactionNotifiers();
_chatController.persistSessionCache();
_restoreViewportAfterMerge(anchor);
_reapplyPinIfNeeded();
}
}
({String id, double at, double alignment})? _captureViewportAnchor() {
if (_pinnedMessageId != null && !_userDidScroll) return null;
if (!_scrollController.hasClients || _isNearBottom()) return null;
final id = _viewportAnchorId();
if (id == null) return null;
final at = _messageContentOffset(id);
final alignment = _messageAlignmentInList(id);
if (at == null || alignment == null) return null;
return (id: id, at: at, alignment: alignment);
}
void _restoreViewportAfterMerge(
({String id, double at, double alignment})? anchor,
) {
_holdReadMarker();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || anchor == null) {
_releaseReadMarker();
return;
}
if (_restoreContentOffset(anchor.id, anchor.at)) {
_releaseReadMarker();
return;
}
_historyAutoloadSuppressCount++;
_alignLoadedMessage(
anchor.id,
anchor.alignment,
0,
epoch: _userGestureEpoch,
onSettled: () {
_historyAutoloadSuppressCount--;
_releaseReadMarker();
},
);
});
}
void _requestCommentCounts() {
if (_commentsMode || !_commentsEnabled) 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(_fetchCommentCounts(pending));
}
// #***! запрос не дошёл (нет сети, таймаут, реконнект) — снимаем отметку,
// #***! иначе посты так и останутся без счётчика до переоткрытия чата
Future<void> _fetchCommentCounts(List<String> postIds) async {
final info = await commentsModule.fetchInfo(
accountId: _myId,
chatId: widget.chatId,
postIds: postIds,
);
if (!mounted) return;
if (info == null) {
_commentCountsRequested.removeAll(postIds);
return;
}
_onCommentsInfo(commentsModule.infoSnapshot);
}
void _onCommentsInfo(Map<String, CommentsInfo> info) {
if (!mounted) return;
if (_commentsMode) 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 l10n = AppLocalizations.of(context)!;
final count = _commentCounts[postId];
if (count == null || count == 0) return l10n.commentsWrite;
return l10n.commentsCount(count);
}
void _openComments(CachedMessage post) {
Navigator.of(context)
.push(
MaterialPageRoute(
builder: (_) => ChatScreen(
chatId: widget.chatId,
name: _chatName,
imageUrl: _chatImageUrl,
chatType: 'CHANNEL',
commentPostId: post.id,
postMessage: _stripInlineKeyboard(post),
),
),
)
.then((_) => _refreshCommentCount(post.id));
}
void _refreshCommentCount(String postId) {
if (!mounted) return;
_commentCountsRequested.remove(postId);
_requestCommentCounts();
}
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;
_deferredIds.clear();
_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();
if (!nearBottom) _deferredIds.add(comment.id);
_messages.add(comment);
_syncReactionNotifiersFromMessages();
_bumpMessages();
unawaited(_resolveCommentNames([comment]));
if (nearBottom) {
_scrollToBottom();
} else {
_noteMissedMessage();
}
}
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;
final info = m.payload?['reactionInfo'];
_reactionNotifiers[m.id] = ValueNotifier(
info is Map ? Map<String, dynamic>.from(info) : null,
);
}
}
@override
void deactivate() {
_saveDraft();
super.deactivate();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden ||
state == AppLifecycleState.detached) {
if (_voiceRec.isRecording.value) {
unawaited(_voiceRec.stop(cancel: true));
}
if (_note.isRecording.value) {
unawaited(_note.stop(cancel: true));
}
}
if (state != AppLifecycleState.resumed) _saveDraft();
super.didChangeAppLifecycleState(state);
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
final view = View.of(context);
final keyboardOpen = view.viewInsets.bottom / view.devicePixelRatio > 100;
if (_keyboardWasOpen && !keyboardOpen && _messageFocusNode.hasFocus) {
_messageFocusNode.unfocus();
}
_keyboardWasOpen = keyboardOpen;
}
@override
void dispose() {
ChatScreen._open.remove(this);
if (!_commentsMode && !widget.preview) {
unawaited(NotificationBridge.instance.popActiveChat(widget.chatId));
}
_chatController.persistSessionCache();
if (_previewChat) {
unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false));
}
WidgetsBinding.instance.removeObserver(this);
_uploadEventSub?.cancel();
chats.chatsChanged.removeListener(_onChatsBump);
_otherUnread.dispose();
_animojiHold.dispose();
_saveDraft();
_messageController.removeListener(_onTextChanged);
_scrollController.removeListener(_onScrollForDate);
_scrollController.removeListener(_maybeLoadMoreHistory);
_scrollController.removeListener(_recordScrollPixels);
_scrollController.removeListener(_scheduleReadMarker);
_scrollController.removeListener(_updateScrollDownVisible);
_readMarker.dispose();
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
if (!widget.preview) MediaPlayback.instance.leaveChat(widget.chatId);
AppChatChrome.current.removeListener(_onVisualStyleChanged);
AppComposerStyle.current.removeListener(_onVisualStyleChanged);
AppComposerBackground.current.removeListener(_onVisualStyleChanged);
_composerHeight.dispose();
_pinnedBannerHeight.dispose();
_floatingDateTimer?.cancel();
_floatingDateCurved.dispose();
_floatingDateAnimController.dispose();
_floatingDate.dispose();
_scrollDownCurved.dispose();
_scrollDownAnimController.dispose();
_newMessageCount.dispose();
_unreadMessageCount.dispose();
_hasText.dispose();
_scheduledCount.dispose();
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
_showAttachmentPanel.dispose();
_detachUploadStatus();
_pushSub?.cancel();
_reactionUpdatesSub?.cancel();
_forwardedSub?.cancel();
_departedSub?.cancel();
_messageEventSub?.cancel();
_commentsInfoSub?.cancel();
_commentSub?.cancel();
_connSub?.cancel();
_voiceRec.dispose();
_note.dispose();
debugForceOffline.removeListener(_recomputeHeaderStatus);
for (final n in _reactionNotifiers.values) {
n.dispose();
}
_reactionNotifiers.clear();
_reactionAnimation.dispose();
ChatActivityStore.instance
.listenable(widget.chatId)
.removeListener(_recomputeHeaderStatus);
ChatMembersStore.instance
.listenable(widget.chatId)
.removeListener(_recomputeHeaderStatus);
PresenceFetch.revision.removeListener(_onPresenceChanged);
ContactsModule.revision.removeListener(_onContactsChanged);
if (_wallpaperListening) {
ChatWallpaperStore.instance.revision.removeListener(
_applyEffectiveWallpaper,
);
}
if (_encryptionListening) {
ChatEncryptionStore.instance.revision.removeListener(_applyEncryption);
E2eeService.instance.revision.removeListener(_applyEncryption);
}
_headerStatusNotifier.dispose();
_otherReadTime.dispose();
_chatController.dispose();
_prank.dispose();
_uploadStatus.dispose();
_attachAnim.dispose();
_commandPanel.dispose();
_mentionPanel.dispose();
_selectionAnim.dispose();
_searchAnim.dispose();
_searchFocusNode.dispose();
_search.dispose();
_selectedIds.dispose();
_textSelection.dispose();
_textSelectionDrag.dispose();
_messageController.dispose();
_messageFocusNode.dispose();
_stickers.dispose();
_scrollController.dispose();
_shimmerStartTimer?.cancel();
_shimmerController.dispose();
_replyTo.dispose();
_pendingForward.dispose();
_reactionSettings.dispose();
_highlightTimer?.cancel();
_highlightMessageId.dispose();
_goToMessageSettleTimer?.cancel();
_jumpCacheExtent.dispose();
_routeSettle.dispose();
_messageKeys.clear();
super.dispose();
}
void _onTextChanged() {
final newHasText = _messageController.text.trim().isNotEmpty;
if (newHasText != _hasText.value) {
_hasText.value = newHasText;
}
_commandPanel.update();
_mentionPanel.update();
}
bool _mentionsAvailable() =>
!_commentsMode && (chat?.type ?? widget.chatType) == 'CHAT';
// #***! отвечать можно только там, где вообще есть поле ввода: в канале без
// прав и в непросмотренной подписке композер заменён плашкой, и «Ответить»
// раньше открывало ответ в никуда
bool get _canReply {
if (_commentsMode) return true;
final type = chat?.type ?? widget.chatType;
if (type == 'CHANNEL' || type == 'CHAT' || type == 'GROUP') {
if (_previewChat) return false;
}
if (type != 'CHANNEL') return true;
return _canPostToChannel;
}
void _onMentionSelected(MentionCandidate candidate, MentionQuery query) {
_messageController.insertMention(
userId: candidate.id,
name: candidate.name,
start: query.start,
end: query.end,
);
_mentionPanel.update();
_messageFocusNode.requestFocus();
}
Future<void> _onCommandSelected(SlashCommand c) async {
var args = '';
if (c.arguments.isNotEmpty) {
final result = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
builder: (_) => CommandArgumentsSheet(command: c),
);
if (!mounted || result == null) return;
args = result;
}
final text = '${c.name} $args';
_messageController.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
_messageFocusNode.requestFocus();
}
void _restoreDraft() {
if (_myId == 0 || _commentsMode || _messageController.text.isNotEmpty) {
return;
}
final shared = widget.initialText?.trim();
final draft = (shared != null && shared.isNotEmpty)
? shared
: DraftStore.instance.get(_myId, widget.chatId);
if (draft == null || draft.isEmpty) return;
_messageController.text = draft;
_messageController.selection = TextSelection.collapsed(
offset: draft.length,
);
}
void _saveDraft() {
if (_myId == 0 || _commentsMode || widget.preview) return;
// #***! черновик зашифрованного чата осел бы на диске открытым текстом
if (_encryptionEnabled) {
if (DraftStore.instance.get(_myId, widget.chatId) != null) {
unawaited(DraftStore.instance.clear(_myId, widget.chatId));
}
return;
}
unawaited(
DraftStore.instance.set(
_myId,
widget.chatId,
_messageController.buildContent().text,
),
);
}
void _onAttachPanelToggle() {
if (_showAttachmentPanel.value) {
_attachAnim.forward();
} else {
_attachAnim.reverse();
}
}
int _computeOtherReadTime() {
final c = chat;
if (c == null) return 0;
int otherReadTime = 0;
for (final entry in c.participants.entries) {
if (entry.key != _myId && entry.value > otherReadTime) {
otherReadTime = entry.value;
}
}
return otherReadTime;
}
void _syncOtherReadTime() {
final t = _computeOtherReadTime();
if (_otherReadTime.value != t) _otherReadTime.value = t;
}
String? _effectiveStatus(CachedMessage msg) {
if (msg.senderId != _myId) return null;
if (msg.status == 'sending' ||
msg.status == 'pending' ||
msg.status == 'error') {
return msg.status;
}
return 'sent';
}
void _onIncomingPush(Packet packet) {
if (!mounted) return;
switch (packet.opcode) {
case Opcode.notifMark:
_onMessageRead(packet);
case Opcode.notifTyping:
_onTyping(packet);
case Opcode.notifMsgDelayed:
final p = packet.payload;
if (p is Map && p['chatId'] == widget.chatId) {
// lastDelayedUpdateTime — авторитетный признак от сервера:
// 0 — отложенных в чате не осталось, иначе они есть. Реагируем
// мгновенно по пушу, не дожидаясь повторного запроса.
final t = p['lastDelayedUpdateTime'];
if (t is int && t == 0) {
_scheduledCount.value = 0;
} else {
if (_scheduledCount.value == 0) _scheduledCount.value = 1;
_refreshScheduledCount();
}
}
}
}
void _markHasScheduled() {
if (_scheduledCount.value == 0) _scheduledCount.value = 1;
}
Future<void> _refreshScheduledCount() async {
if (_myId == 0) return;
try {
final list = await messagesModule.fetchDelayedMessages(
_myId,
widget.chatId,
);
if (mounted) _scheduledCount.value = list.length;
} catch (_) {}
}
void _bumpMessages() {
_combinedItemsCache = null;
_syncUnreadMessageCount();
_messagesRev.value++;
}
void _enterSelection(CachedMessage message) {
if (message.isControl) return;
Haptics.medium();
if (_selectedIds.value.contains(message.id)) return;
_selectedIds.value = {..._selectedIds.value, message.id};
_syncSelectionAnim();
}
void _toggleSelection(CachedMessage message) {
if (message.isControl) return;
final next = Set<String>.from(_selectedIds.value);
if (!next.remove(message.id)) next.add(message.id);
Haptics.selection();
_selectedIds.value = next;
if (!next.contains(message.id)) _exitTextSelection(message.id);
_syncSelectionAnim();
}
void _clearSelection() {
_exitTextSelection();
if (_selectedIds.value.isEmpty) return;
_selectedIds.value = const {};
_syncSelectionAnim();
}
void _startTextSelection(CachedMessage message, Offset globalPosition) {
if (message.isControl ||
MessageDecryptionCache.instance.readableText(message) == null) {
return;
}
_textSelectionDrag.value = null;
_textSelection.value = (id: message.id, pos: globalPosition);
}
void _exitTextSelection([String? onlyId]) {
final current = _textSelection.value;
if (current == null) return;
if (onlyId != null && current.id != onlyId) return;
_textSelection.value = null;
}
void _syncSelectionAnim() {
if (_selectedIds.value.isEmpty) {
_selectionAnim.reverse();
} else if (_selectionAnim.status != AnimationStatus.forward &&
_selectionAnim.value < 1) {
_selectionAnim.forward();
}
}
List<CachedMessage> _selectedMessages(Set<String> ids) =>
_messages.where((m) => ids.contains(m.id)).toList();
List<CachedMessage> _copyableSelection(Set<String> ids) => [
for (final m in _messages)
if (ids.contains(m.id) &&
(MessageDecryptionCache.instance.readableText(m) ?? '').isNotEmpty)
m,
];
CachedMessage? _singleEditable(Set<String> ids) {
if (ids.length != 1) return null;
final list = _selectedMessages(ids);
if (list.isEmpty) return null;
return _canEditMessage(list.first) ? list.first : null;
}
void _copySelected(List<CachedMessage> messages) {
if (messages.isEmpty) return;
final text = messages
.map((m) => MessageDecryptionCache.instance.readableText(m) ?? '')
.where((text) => text.isNotEmpty)
.join('\n\n');
if (text.isEmpty) return;
Clipboard.setData(ClipboardData(text: text));
Haptics.tap();
showCustomNotification(
context,
AppLocalizations.of(context)!.msgActionsCopied,
);
_clearSelection();
}
void _editSelected(CachedMessage message) {
_clearSelection();
_startEditMessage(message);
}
Future<void> _deleteSelected() async {
final msgs = _selectedMessages(_selectedIds.value);
if (msgs.isEmpty) return;
final serverMsgs = msgs.where((m) => !m.id.startsWith('temp_')).toList();
if (serverMsgs.isEmpty) {
for (final m in msgs) {
_startDeleteAnimation(m.id);
}
_clearSelection();
return;
}
final canForEveryone = serverMsgs.every((m) => m.senderId == _myId);
final forEveryone = await _showDeleteMessageDialog(canForEveryone);
if (forEveryone == null || !mounted) return;
_clearSelection();
final accountId = _myId;
showUndoNotification(
context,
'Удаление сообщений через 5 секунд',
onUndo: () {},
onCommit: () async {
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
final ok = await messagesModule.deleteMessages(
widget.chatId,
serverMsgs.map((m) => m.id).toList(),
forEveryone: forEveryone,
);
if (ok) {
for (final message in serverMsgs) {
await AppDatabase.deleteMessage(
accountId,
widget.chatId,
message.id,
);
}
MessageSessionCache.remove(accountId, widget.chatId);
await chats.reconcileLastMessage(accountId, widget.chatId);
}
if (!mounted) return;
if (!ok) {
Haptics.error();
showCustomNotification(context, 'Не удалось удалить сообщения');
return;
}
for (final m in msgs) {
_startDeleteAnimation(m.id);
}
_clearSelection();
},
);
}
void _replySelected() {
final msgs = _selectedMessages(_selectedIds.value);
if (msgs.isEmpty) return;
final message = msgs.first;
_clearSelection();
_startReply(message);
}
void _forwardSelected() {
final msgs = _selectedMessages(_selectedIds.value);
_clearSelection();
unawaited(_forwardMessages(msgs));
}
// #***! пересылка это серверная копия, текст подставляет сервер а не мы
Future<void> _forwardMessages(List<CachedMessage> msgs) async {
final forwardable = msgs
.where((message) => int.tryParse(message.id) != null)
.toList();
if (forwardable.isEmpty) {
showCustomNotification(
context,
AppLocalizations.of(context)!.chatScreenNothingToForward,
);
return;
}
if (_encryptionEnabled) {
showCustomNotification(
context,
AppLocalizations.of(context)!.e2eeForwardBlocked,
);
return;
}
final ordered = [...forwardable]..sort((a, b) => a.time.compareTo(b.time));
final request = ForwardRequest(
sourceChatId: widget.chatId,
sourceChatName: _chatName,
sourceChatIconUrl: _chatImageUrl,
sourceChatType: widget.chatType,
messages: ordered,
);
final target = await openForwardScreen(
context: context,
messageCount: forwardable.length,
batch: request,
);
if (target == null || !mounted) return;
if (target.chatId == widget.chatId) {
_setForwardRequest(request);
return;
}
pushSwipeable(
context,
(_) => ChatScreen(
chatId: target.chatId,
name: target.name,
imageUrl: target.imageUrl,
chatType: target.chatType,
forwardRequest: request,
),
);
}
void _setForwardRequest(ForwardRequest request) {
_cancelReply();
_forwardRequest = request;
_pendingForward.value = request;
}
void _cancelForward() {
_forwardRequest = null;
_pendingForward.value = null;
}
Future<bool> _sendForwardRequest() async {
var request = _forwardRequest;
if (request == null) return true;
if (_encryptionEnabled) {
showCustomNotification(
context,
AppLocalizations.of(context)!.e2eeForwardBlocked,
);
return false;
}
if (api.state != SessionState.online) {
showCustomNotification(context, 'Нет соединения');
return false;
}
Haptics.send();
while (request != null && request.messages.isNotEmpty) {
if (!identical(_forwardRequest, request)) return false;
final result = await ForwardSender.send(
accountId: _myId,
chatIds: [widget.chatId],
request: request.withMessages([request.messages.first]),
);
if (!result.delivered.contains(widget.chatId)) {
if (mounted) showCustomNotification(context, 'Не удалось переслать');
return false;
}
if (!mounted) return false;
if (!identical(_forwardRequest, request)) return false;
final remaining = request.messages.skip(1).toList(growable: false);
if (remaining.isEmpty) {
_cancelForward();
return true;
}
request = request.withMessages(remaining);
_forwardRequest = request;
_pendingForward.value = request;
}
_cancelForward();
return true;
}
Widget _buildComposerArea(BuildContext context) {
if (widget.preview) return const SizedBox.shrink();
final cs = Theme.of(context).colorScheme;
final content = Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.chatType == 'DIALOG' && _myId != 0)
E2eeBanner(
accountId: _myId,
chatId: widget.chatId,
peerName: widget.name,
onOpenDetails: _openEncryptionSettings,
),
AnimatedBuilder(
animation: _selectionAnim,
builder: (context, child) {
final t = Curves.easeOut.transform(
_selectionAnim.value.clamp(0.0, 1.0),
);
if (t == 0) return child!;
if (t == 1) return const SizedBox.shrink();
return ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: 1 - t,
child: Transform.translate(
offset: Offset(0, 48 * t),
child: Opacity(opacity: 1 - t, child: child),
),
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _attachAnim,
builder: (context, _) {
if (_attachAnim.value == 0) {
return const SizedBox.shrink();
}
final curve = _attachAnim.status == AnimationStatus.reverse
? Curves.easeIn
: Curves.easeOut;
final t = curve.transform(_attachAnim.value);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: t,
child: Opacity(
opacity: t,
child: AttachmentPanel(
onClose: () => _showAttachmentPanel.value = false,
onPickFile: _pickAndUploadFile,
onSendById: _sendFileById,
),
),
),
),
);
},
),
AnimatedBuilder(
animation: Listenable.merge([_stickers.anim, _messagesRev]),
builder: (context, _) => _botStartDue
? ComposerPillBar(
label: AppLocalizations.of(context)!.botStart,
primary: true,
onTap: _startBot,
)
: ComposerInputBar(
canPostToChannel: _canPostToChannel,
bottomSafe: _stickers.anim.value == 0,
chatType: _commentsMode ? 'CHAT' : widget.chatType,
chrome: _effectiveChrome,
vignette: _chromeVignette,
style: AppComposerStyle.current.value,
background: AppComposerBackground.current.value,
backdropKey: _pillBackdrop,
attachAnim: _attachAnim,
replyTo: _replyTo,
forward: _pendingForward,
onToggleForwardSender: () {
final value = _forwardRequest;
if (value != null && value.canHideSender) {
_setForwardRequest(
value.withHideSender(!value.hideSender),
);
}
},
myId: _myId,
hasText: _hasText,
uploadStatus: _uploadStatus,
messageController: _messageController,
messageFocusNode: _messageFocusNode,
voiceRec: _voiceRec,
note: _note,
onToggleStickerPanel: _toggleStickerPanel,
onSendText: _sendMessage,
onScheduleMessage: _scheduleMessage,
onOpenAttach: _openAttachmentSheet,
onOpenAttachScheduled: _openAttachmentSheetScheduled,
onSendHistory: _sendHistoryFile,
onCancelReply: _cancelReply,
onCancelForward: _cancelForward,
onPickReplyChat:
_commentsMode || !_crossChatReplySupported
? null
: () => unawaited(_pickReplyChat()),
formatElapsed: formatVoiceElapsed,
contextMenuBuilder: (ctx, state) => _formatContextMenu(
_messageController,
ctx,
state,
pasteItem: _pasteMenuItem(ctx, state),
),
onPasteMedia: ClipboardMedia.supported
? _handlePasteMedia
: null,
onInsertContent: _insertKeyboardContent,
isMuted: chat?.isMuted ?? false,
onToggleMute: _toggleChatMute,
channelSubscribed: !_previewChat,
channelSubscribing: _subscribing,
onSubscribe: _subscribeChannel,
showStickerButton: !_commentsMode,
showAttachButton: !_commentsMode,
forceSend: _commentsMode,
hintText: _commentsMode ? 'Комментарий' : 'Message',
),
),
StickerPanelView(
stickers: _stickers,
onStickerTap: _sendSticker,
onEmojiTap: _insertAnimoji,
),
],
),
),
AnimatedBuilder(
animation: _selectionAnim,
builder: (context, child) {
final t = Curves.easeOut.transform(
_selectionAnim.value.clamp(0.0, 1.0),
);
if (t == 0) return const SizedBox.shrink();
return ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: t,
child: Opacity(opacity: t, child: child),
),
);
},
child: ValueListenableBuilder<Set<String>>(
valueListenable: _selectedIds,
builder: (context, selected, _) => SelectionBottomBar(
cs: cs,
selected: selected,
onReply: _replySelected,
onForward: _forwardSelected,
allowForward: !(chat?.forwardDisabled ?? false),
allowReply: _canReply,
),
),
),
],
);
Widget wrapChrome(Widget child) {
if (_composerFrosted) {
if (ComposerChrome.isGlossy(AppComposerStyle.current.value)) {
return child;
}
return _FrostedPanel(
sigma: AppFrost.sigma,
tint: AppFrost.glassTint(cs),
border: Border(top: AppFrost.hairline(cs)),
backdropKey: _barBackdrop,
child: child,
);
}
if (_effectiveChrome != ChatChromeStyle.blur) return child;
return _FrostedPanel(
tint: AppFrost.blurPanelTint(cs),
border: Border(top: AppFrost.hairline(cs)),
backdropKey: _barBackdrop,
child: child,
);
}
final base = ToastObstruction(child: wrapChrome(content));
return AnimatedBuilder(
animation: _searchAnim,
builder: (context, _) {
final s = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0));
if (s == 0) return base;
if (s >= 1) return const SizedBox.shrink();
return ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: 1 - s,
child: Opacity(
opacity: 1 - s,
child: IgnorePointer(child: base),
),
),
);
},
);
}
bool _canEditMessage(CachedMessage message) {
if (message.senderId != _myId) return false;
if (message.id.startsWith('temp_')) return false;
if (message.isControl) return false;
final status = message.status;
if (status == 'sending' || status == 'error') return false;
return true;
}
Future<void> _startEditMessage(CachedMessage message) async {
final originalText = await MessageDecryptionCache.instance.resolveText(
message,
);
if (!mounted) return;
if (originalText == null) {
showCustomNotification(context, 'Не удалось расшифровать сообщение');
return;
}
final cs = Theme.of(context).colorScheme;
final content =
await showModalBottomSheet<
({String text, List<Map<String, dynamic>> elements})
>(
context: context,
isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => _EditMessageSheet(
text: originalText,
formatRanges: message.formatRanges,
contextMenuBuilder: _formatContextMenu,
),
);
if (content == null || !mounted) return;
final rawText = content.text;
final newText = rawText.trim();
final elements = _trimmedElements(content.elements, rawText, newText);
final oldElements = serializeFormatElements(
message.formatRanges.where((r) => composerFormats.contains(r.format)),
);
if (newText == originalText && _sameElements(elements, oldElements)) {
return;
}
final wireText = await _encryptOutgoing(newText);
if (wireText == null || !mounted) return;
final editEncrypted = wireText != newText;
final ok = await messagesModule.editMessage(
widget.chatId,
message.id,
text: wireText,
elements: editEncrypted ? const [] : elements,
);
if (!mounted) return;
if (!ok) {
Haptics.error();
showCustomNotification(
context,
AppLocalizations.of(context)!.scheduledEditFailed,
);
return;
}
final idx = _messages.indexWhere((m) => m.id == message.id);
if (idx != -1) {
final old = _messages[idx];
final newHistory = QlyraSettings.viewRedacted.value
? CachedMessage.appendEditHistory(
old.editHistory,
old.text,
DateTime.now().millisecondsSinceEpoch,
)
: old.editHistory;
final edited = CachedMessage(
id: old.id,
accountId: old.accountId,
chatId: old.chatId,
senderId: old.senderId,
text: wireText.isEmpty ? null : wireText,
time: old.time,
status: 'EDITED',
payload: {
...?old.payload,
'elements': editEncrypted ? const [] : elements,
},
attachments: old.attachments,
isControl: old.isControl,
editHistory: newHistory,
sealedText: _e2eeActive
? await E2eeService.instance.sealText(_myId, widget.chatId, newText)
: null,
e2ee: _e2eeActive ? CachedMessage.e2eeText : CachedMessage.e2eeNone,
);
if (editEncrypted) {
MessageDecryptionCache.instance.seed(message.id, newText);
}
_messages[idx] = edited;
_bumpMessages();
unawaited(_persistOutgoing(edited));
}
Haptics.send();
}
Future<void> _confirmDeleteMessage(String messageId, bool isMe) async {
final isLocalOnly = messageId.startsWith('temp_');
final canForEveryone = (isMe || _canDeleteOthers) && !isLocalOnly;
if (isLocalOnly) {
_startDeleteAnimation(messageId);
return;
}
final forEveryone = await _showDeleteMessageDialog(canForEveryone);
if (forEveryone == null || !mounted) return;
final accountId = _myId;
showUndoNotification(
context,
'Удаление сообщения через 5 секунд',
onUndo: () {},
onCommit: () async {
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
final ok = await messagesModule.deleteMessages(widget.chatId, [
messageId,
], forEveryone: forEveryone);
if (ok) {
await AppDatabase.deleteMessage(accountId, widget.chatId, messageId);
MessageSessionCache.remove(accountId, widget.chatId);
await chats.reconcileLastMessage(accountId, widget.chatId);
}
if (!mounted) return;
if (!ok) {
Haptics.error();
showCustomNotification(context, 'Не удалось удалить сообщение');
return;
}
_startDeleteAnimation(messageId);
},
);
}
void _startDeleteAnimation(String messageId) {
if (!_deletingIds.add(messageId)) return;
Haptics.tap();
_bumpMessages();
}
Future<void> _finalizeDelete(String messageId) async {
if (!mounted) return;
_deletingIds.remove(messageId);
final idx = _messages.indexWhere((m) => m.id == messageId);
if (idx != -1) {
_messages.removeAt(idx);
_reactionNotifiers.remove(messageId)?.dispose();
}
_bumpMessages();
try {
await AppDatabase.deleteMessage(_myId, widget.chatId, messageId);
await chats.reconcileLastMessage(_myId, widget.chatId);
} catch (_) {}
}
Future<bool?> _showDeleteMessageDialog(bool canForEveryone) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
var alsoForEveryone = canForEveryone;
return showDialog<bool>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setLocalState) {
return AlertDialog(
backgroundColor: cs.surfaceContainerHigh,
shape: AppShape.dialogBorder,
title: Text(l10n.chatScreenDeleteMessageTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.chatScreenDeleteMessageConfirm,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
),
if (canForEveryone) ...[
const SizedBox(height: 16),
InkWell(
onTap: () => setLocalState(
() => alsoForEveryone = !alsoForEveryone,
),
borderRadius: BorderRadius.circular(8),
child: Row(
children: [
Checkbox(
value: alsoForEveryone,
onChanged: (v) => setLocalState(
() => alsoForEveryone = v ?? false,
),
),
Expanded(
child: Text(
l10n.chatScreenDeleteAlsoFor(widget.name),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
),
),
),
],
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.chatInfoActionCancel),
),
TextButton(
onPressed: () =>
Navigator.pop(ctx, canForEveryone && alsoForEveryone),
child: Text(
l10n.msgActionsDelete,
style: TextStyle(color: cs.error),
),
),
],
);
},
);
},
);
}
void _onMessageEvent(MessageEvent event) {
if (!mounted) return;
if (_commentsMode) return;
switch (event) {
case MessageAddedEvent(:final message):
if (message.senderId == _myId && !message.isControl) return;
if (_messages.any((m) => m.id == message.id)) return;
_notePeerReadThrough(message);
final nearBottom = _isNearBottom();
if (!nearBottom) _deferredIds.add(message.id);
_lastSentId = message.id;
_messages.add(message);
_bumpMessages();
_clearTyping(message.senderId);
Haptics.tap();
if (nearBottom) {
_scrollToBottom();
_scheduleReadMarker();
} else {
_noteMissedMessage();
_reapplyPinIfNeeded();
}
_prank.checkTrigger(message);
case MessageEditedEvent(:final message):
final idx = _messages.indexWhere((m) => m.id == message.id);
if (idx == -1) return;
_messages[idx] = message;
_bumpMessages();
case MessageSentEvent(:final tempId, :final message):
if (message.accountId != _myId) return;
var idx = _messages.indexWhere((m) => m.id == tempId);
if (idx == -1) return;
if (message.id != tempId) {
_messages.removeWhere((m) => m.id == message.id);
idx = _messages.indexWhere((m) => m.id == tempId);
}
_lastSentId = message.id;
_messages[idx] = message;
_bumpMessages();
if (message.status == 'sent') {
unawaited(_refreshChatAfterSend(message.accountId));
}
case MessageRemovedEvent(:final messageId):
final idx = _messages.indexWhere((m) => m.id == messageId);
if (idx == -1) return;
_messages.removeAt(idx);
_bumpMessages();
_reactionNotifiers.remove(messageId)?.dispose();
case MessageMarkedDeletedEvent(:final messageId):
final idx = _messages.indexWhere((m) => m.id == messageId);
if (idx == -1) return;
if (_messages[idx].deleted) return;
_messages[idx] = _messages[idx].copyWith(deleted: true);
_bumpMessages();
case MessageReactionsChangedEvent(:final messageId, :final reactionInfo):
_reactionNotifiers[messageId]?.value = reactionInfo;
}
}
Future<void> _loadOtherPresence() async {
if (_myId == 0) return;
final otherId = widget.chatId ^ _myId;
if (otherId <= 0) return;
if (PresenceFetch.live(otherId) != null) return;
try {
final entry = await PresenceFetch.get(otherId);
if (!mounted || entry == null) return;
PresenceFetch.apply(otherId, entry);
} catch (_) {}
}
void _onPresenceChanged() {
if (!mounted) return;
final otherId = _resolveOtherId();
if (otherId == null) return;
final p = PresenceFetch.live(otherId);
if (p == null) return;
_otherStatus = (p['status'] as int?) ?? 0;
_otherSeenTime = p['seen'] as int?;
_recomputeHeaderStatus();
}
void _onVisualStyleChanged() {
if (mounted) {
setState(() {});
_bumpMessages();
}
}
void _onContactsChanged() {
if (mounted) setState(() {});
}
String _headerAvatarUrl() {
if (!_commentsMode && widget.chatType == 'DIALOG') {
final otherId = _resolveOtherId();
if (otherId != null) {
final cached = ContactCache.getAvatar(otherId);
if (cached != null && cached.isNotEmpty) return cached;
}
}
return _chatImageUrl;
}
String get _chatName {
final title = chat?.title;
if (widget.chatType == 'DIALOG' || title == null || title.isEmpty) {
return widget.name;
}
return title;
}
String get _chatImageUrl {
final icon = chat?.iconUrl;
if (widget.chatType == 'DIALOG' || icon == null || icon.isEmpty) {
return widget.imageUrl;
}
return icon;
}
String _headerName() {
if (_commentsMode) return AppLocalizations.of(context)!.commentsTitle;
if (widget.chatType == 'DIALOG') {
final otherId = _resolveOtherId();
if (otherId != null) {
final cached = ContactCache.get(otherId);
if (cached != null && cached.isNotEmpty) return cached;
}
}
return _chatName;
}
PreferredSizeWidget _buildAppBar(ColorScheme cs) {
final glossy = AppVisualStyle.current.value.glossyChrome;
final searchT = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0));
final height = glossy
? ui.lerpDouble(_glossyHeaderHeight, _glossySearchHeight, searchT)!
: kToolbarHeight;
final chrome = _effectiveChrome;
final barExtent = MediaQuery.paddingOf(context).top + height;
final fadeStop = ((barExtent - _edgeFadeHeight) / barExtent).clamp(
0.0,
1.0,
);
return AppBar(
backgroundColor: chrome == ChatChromeStyle.color
? (glossy ? Colors.transparent : cs.surfaceContainerHigh)
: Colors.transparent,
flexibleSpace: chrome == ChatChromeStyle.blur
? _FrostedPanel(
tint: AppFrost.blurPanelTint(cs),
border: Border(bottom: AppFrost.hairline(cs)),
backdropKey: _barBackdrop,
child: const SizedBox.expand(),
)
: (_chromeVignette && !glossy)
? IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
cs.surface,
cs.surface,
cs.surface.withValues(alpha: 0.0),
],
stops: [0.0, fadeStop, 1.0],
),
),
child: const SizedBox.expand(),
),
)
: (chrome == ChatChromeStyle.transparent && !glossy)
? _FrostedPanel(
sigma: AppFrost.sigma,
tint: AppFrost.glassTint(cs),
border: Border(bottom: AppFrost.hairline(cs)),
backdropKey: _barBackdrop,
child: const SizedBox.expand(),
)
: null,
foregroundColor: cs.onSurface,
surfaceTintColor: Colors.transparent,
iconTheme: IconThemeData(color: cs.onSurface),
elevation: 0,
toolbarHeight: height,
automaticallyImplyLeading: false,
titleSpacing: 0,
centerTitle: false,
title: SizedBox(
height: height,
child: AnimatedBuilder(
animation: Listenable.merge([_selectionAnim, _searchAnim]),
builder: (context, _) {
final t = Curves.easeOut.transform(
_selectionAnim.value.clamp(0.0, 1.0),
);
final s = Curves.easeOut.transform(
_searchAnim.value.clamp(0.0, 1.0),
);
return ValueListenableBuilder<Set<String>>(
valueListenable: _selectedIds,
builder: (context, selected, _) => Stack(
fit: StackFit.expand,
children: [
if (t < 1 && s < 1)
IgnorePointer(
ignoring: t > 0.5 || s > 0.5,
child: Opacity(
opacity: (1 - t) * (1 - s),
child: Transform.translate(
offset: Offset(0, -height * 0.4 * t),
child: ChatHeaderRow(
glossy: glossy,
frosted:
glossy && chrome == ChatChromeStyle.transparent,
backdropVisible: t == 0 && s == 0,
liquid: _liquidChrome,
backdropKey: _pillBackdrop,
cs: cs,
embedded: widget.embedded,
chatId: widget.chatId,
heroTag: _profileHeroTag,
name: _headerName(),
imageUrl: _headerAvatarUrl(),
chatType: widget.chatType,
isOfficial: chat?.isOfficial ?? false,
encrypted: _encryptionEnabled,
verified: _e2eeVerified,
myId: _myId,
headerStatus: _headerStatusNotifier,
scheduledCount: _scheduledCount,
otherUnread: _otherUnread,
showCall:
!_commentsMode &&
widget.chatType == 'DIALOG' &&
widget.chatId != 0 &&
!_peerIsBot,
onClose: widget.onClose,
onOpenInfo: _commentsMode ? () {} : _openChatInfo,
onOpenScheduled: _openScheduledMessages,
onCall: _startCall,
onMenu: _commentsMode ? (_) {} : _openChatMenu,
),
),
),
),
if (t > 0)
IgnorePointer(
ignoring: t < 0.5,
child: Opacity(
opacity: t,
child: Transform.translate(
offset: Offset(0, height * 0.4 * (1 - t)),
child: SelectionTopBar(
cs: cs,
selected: selected,
glossy: glossy,
copyMsgs: _copyableSelection(selected),
editMsg: _singleEditable(selected),
onClear: _clearSelection,
onCopy: _copySelected,
onEdit: _editSelected,
onDelete: _deleteSelected,
),
),
),
),
if (s > 0)
IgnorePointer(
ignoring: s < 0.5,
child: Opacity(
opacity: s,
child: SearchTopBar(
cs: cs,
glossy: glossy,
search: _search,
focusNode: _searchFocusNode,
onClose: _closeSearch,
),
),
),
],
),
);
},
),
),
);
}
bool get _hasMiniApp {
if (widget.chatType != 'DIALOG' || _commentsMode) return false;
final peerId = _resolveOtherId();
if (peerId == null) return false;
if (hasMiniAppOption(ContactCache.getOptions(peerId))) return true;
return hasMiniAppOption(chat?.options);
}
Future<void> _openMiniApp() async {
final peerId = _resolveOtherId();
if (peerId == null) return;
await openMiniApp(
context,
botId: peerId,
chatId: widget.chatId,
title: _headerName(),
);
}
void _openChatMenu(BuildContext btnContext) {
final box = btnContext.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
final anchorRect = box.localToGlobal(Offset.zero) & box.size;
final l10n = AppLocalizations.of(context)!;
showChatMenu(
context: context,
anchorRect: anchorRect,
items: [
if (_hasMiniApp)
ChatMenuItem(
icon: Symbols.apps,
label: l10n.miniAppOpen,
dividerAfter: true,
onTap: () => unawaited(_openMiniApp()),
),
ChatMenuItem(
icon: (chat?.isMuted ?? false)
? Symbols.volume_off
: Symbols.volume_up,
label: (chat?.isMuted ?? false)
? l10n.notificationsFkmEnableLabel
: l10n.chatScreenMenuMute,
dividerAfter: true,
onTap: _toggleChatMute,
),
ChatMenuItem(
icon: Symbols.search,
label: l10n.chatInfoMembersSearchHint,
onTap: _openSearch,
),
ChatMenuItem(
icon: Symbols.wallpaper,
label: l10n.chatScreenMenuChangeWallpaper,
onTap: _openWallpaperSheet,
),
ChatMenuItem(
icon: Symbols.mop,
label: l10n.chatInfoMenuClearHistory,
onTap: _clearHistory,
),
ChatMenuItem(
icon: _encryptionEnabled ? Symbols.lock : Symbols.lock_open,
label: l10n.chatScreenMenuEncryption,
onTap: _openEncryptionSettings,
),
ChatMenuItem(
icon: Symbols.delete,
label: l10n.chatInfoMenuDeleteChat,
onTap: _deleteChat,
),
],
);
}
Future<void> _subscribeChannel() async {
if (_subscribing) return;
setState(() => _subscribing = true);
try {
var link = _channelLink;
if (link == null || link.isEmpty) {
final info = await chats.getChatInfo(api, widget.chatId);
link = info?['link'] as String?;
}
if (link == null || link.isEmpty) {
throw const PacketError('Не удалось получить ссылку канала');
}
final result = await chats.joinChannel(api, link, _myId);
if (!mounted) return;
setState(() {
_previewChat = false;
_subscribing = false;
chat = result.chat;
});
ChatMembersStore.instance.setCount(
widget.chatId,
result.subscribersCount,
);
_recomputeHeaderStatus();
showCustomNotification(context, 'Вы подписались на канал');
} catch (e) {
if (!mounted) return;
setState(() => _subscribing = false);
showCustomNotification(
context,
e is PacketError ? e.message : 'Не удалось подписаться',
);
}
}
Future<void> _toggleChatMute() async {
final current = chat;
if (current == null) return;
final muted = current.isMuted;
final target = muted ? ChatsModule.muteOff : ChatsModule.muteForever;
final error = await chats.setChatMute(
api,
chatId: widget.chatId,
dontDisturbUntil: target,
);
if (!mounted) return;
if (error != null) {
showCustomNotification(context, error);
return;
}
setState(() => chat = current.copyWith(dontDisturbUntil: target));
showCustomNotification(
context,
muted
? AppLocalizations.of(context)!.chatInfoNotificationsOn
: AppLocalizations.of(context)!.chatInfoNotificationsOff,
);
}
bool _encryptionListening = false;
Future<void> _loadEncryption() async {
await ChatEncryptionStore.instance.load();
await E2eeService.instance.ensureLoaded(_myId);
if (!mounted) return;
if (!_encryptionListening) {
_encryptionListening = true;
ChatEncryptionStore.instance.revision.addListener(_applyEncryption);
E2eeService.instance.revision.addListener(_applyEncryption);
}
_applyEncryption();
}
bool get _e2eeActive =>
widget.chatType == 'DIALOG' &&
E2eeService.instance.isActive(_myId, widget.chatId);
bool get _e2eeVerified =>
_e2eeActive &&
(E2eeService.instance.info(_myId, widget.chatId)?.verified ?? false);
void _applyEncryption() {
if (!mounted) return;
final e2ee = _e2eeActive;
final enabled =
E2eeService.instance.isOn(_myId, widget.chatId) ||
ChatEncryptionStore.instance.isEnabled(_myId, widget.chatId);
if (enabled != _encryptionEnabled) {
setState(() => _encryptionEnabled = enabled);
}
if (enabled && !e2ee && _myId != 0) {
unawaited(ChatCryptoService.instance.warmKey(_myId, widget.chatId));
}
}
Future<void> _openEncryptionSettings() async {
if (_myId == 0) return;
await pushSwipeable(
context,
(context) => widget.chatType == 'DIALOG'
? E2eeScreen(
accountId: _myId,
chatId: widget.chatId,
peerId: _resolveOtherId() ?? 0,
peerName: widget.name,
)
: ChatEncryptionScreen(accountId: _myId, chatId: widget.chatId),
);
if (!mounted) return;
_applyEncryption();
}
bool _wallpaperListening = false;
Future<void> _loadWallpaper() async {
await ChatWallpaperStore.instance.load();
if (!mounted) return;
if (!_wallpaperListening) {
_wallpaperListening = true;
ChatWallpaperStore.instance.revision.addListener(
_applyEffectiveWallpaper,
);
}
_applyEffectiveWallpaper();
}
void _applyEffectiveWallpaper() {
if (!mounted) return;
final store = ChatWallpaperStore.instance;
final wp =
store.get(_myId, widget.chatId) ??
store.get(_myId, kGlobalWallpaperChatId);
if (!identical(wp, _wallpaper)) setState(() => _wallpaper = wp);
}
Future<void> _openWallpaperSheet() async {
if (_myId == 0) return;
final pick = await showChatWallpaperSheet(context, current: _wallpaper);
if (pick == null || !mounted) return;
final store = ChatWallpaperStore.instance;
switch (pick.type) {
case WallpaperPickType.none:
await store.clear(_myId, widget.chatId);
_applyEffectiveWallpaper();
break;
case WallpaperPickType.theme:
final theme = pick.theme;
if (theme == null) break;
await store.setTheme(_myId, widget.chatId, theme.id);
_applyEffectiveWallpaper();
break;
case WallpaperPickType.gradient:
final colors = pick.gradientColors;
if (colors == null || colors.isEmpty) break;
await store.setGradient(
_myId,
widget.chatId,
colors,
animated: pick.gradientAnimated,
rotation: pick.gradientRotation,
);
_applyEffectiveWallpaper();
break;
case WallpaperPickType.gallery:
await _pickWallpaperFromGallery();
break;
}
}
Future<void> _pickWallpaperFromGallery() async {
final bytes = await pickWallpaperBytes(context);
if (bytes == null || !mounted) return;
final settings = await Navigator.of(context).push<WallpaperImageSettings>(
MaterialPageRoute(
builder: (_) => ChatWallpaperPreviewScreen(imageBytes: bytes),
),
);
if (settings == null || !mounted) return;
final wp = await ChatWallpaperStore.instance.setImage(
_myId,
widget.chatId,
bytes,
settings: settings,
);
if (!mounted) return;
if (wp == null) {
showCustomNotification(
context,
AppLocalizations.of(context)!.chatScreenWallpaperSaveFailed,
);
return;
}
_applyEffectiveWallpaper();
}
bool get _canActForAll {
final type = chat?.type ?? widget.chatType;
if (type == 'DIALOG') return widget.chatId != 0;
return (type == 'CHAT' || type == 'CHANNEL') && _canDeleteOthers;
}
Future<void> _clearHistory() async {
final current = chat;
final canClearForAll = _canActForAll;
final l10n = AppLocalizations.of(context)!;
final choice = await showBlurredConfirm(
context,
title: l10n.chatInfoClearHistoryTitle,
message: l10n.chatInfoClearHistoryMessage,
confirmLabel: l10n.chatInfoClearHistoryConfirm,
cancelLabel: l10n.chatInfoActionCancel,
destructive: true,
checkboxLabel: canClearForAll ? l10n.chatInfoClearHistoryForAll : null,
);
if (!mounted || !choice.confirmed) return;
final err = await chats.clearHistory(
api,
chatId: widget.chatId,
lastEventTime: current?.lastEventTime ?? 0,
forAll: canClearForAll && choice.checked,
);
if (!mounted) return;
if (err != null) {
showCustomNotification(context, err);
return;
}
setState(() {
_messages = [];
_deferredIds.clear();
_hasMoreHistory = false;
_combinedItemsCache = null;
});
_messagesRev.value++;
}
Future<void> _deleteChat() async {
final confirmed = await showConfirmDialog(
context,
title: 'Удалить чат',
message: 'Чат будет удалён вместе со всей перепиской.',
confirmLabel: 'Удалить',
destructive: true,
);
if (!mounted || !confirmed) return;
final err = await chats.deleteChat(
api,
chatId: widget.chatId,
lastEventTime: chat?.lastEventTime ?? 0,
forAll: false,
);
if (!mounted) return;
if (err != null) {
showCustomNotification(context, err);
return;
}
Navigator.of(context).pop();
}
Future<void> _startCall() async {
if (widget.chatType != 'DIALOG' || _peerIsBot) {
showCustomNotification(
context,
AppLocalizations.of(context)!.chatScreenCallsDialogsOnly,
);
return;
}
// Звонок уже идёт (возможно, свёрнут) — просто открываем его экран снова.
final navigator = Navigator.of(context);
final active = CallController.instance.activeSession;
if (active != null) {
await navigator.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: widget.name,
avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
session: active,
),
),
);
_onCallScreenClosed();
return;
}
final peerId = widget.chatId ^ _myId;
if (peerId <= 0) return;
try {
final session = await CallController.instance.startOutgoing(peerId);
if (!mounted) return;
await navigator.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: widget.name,
avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
session: session,
),
),
);
_onCallScreenClosed();
} catch (_) {
if (!mounted) return;
showCustomNotification(
context,
AppLocalizations.of(context)!.chatInfoCallFailed,
);
}
}
void _onCallScreenClosed() {
if (!mounted) return;
if (CallController.instance.activeSession != null) return;
unawaited(_refreshAfterCall());
}
Future<void> _refreshAfterCall() async {
await Future.delayed(const Duration(milliseconds: 700));
if (!mounted || _myId == 0) return;
try {
final serverMessages = await messagesModule.fetchHistory(
_myId,
widget.chatId,
);
if (QlyraSettings.viewDeleted.value) {
await chats.reconcileDeletedFromFetch(
_myId,
widget.chatId,
serverMessages,
);
}
final rows = await AppDatabase.loadMessages(
_myId,
widget.chatId,
limit: 100,
onlyVisible: !QlyraSettings.viewDeleted.value,
);
final decoded = await CachedMessage.fromDbRowsAsync(rows);
if (mounted) _applyMergedMessages(decoded);
} catch (e) {
logger.w('Обновление после звонка не удалось: $e');
}
}
void _seedPresenceFromChat() {
if (_otherStatus != 0 || _otherSeenTime != null) return;
final otherId = _resolveOtherId();
if (otherId == null) return;
final p = PresenceFetch.live(otherId);
if (p == null) return;
_otherStatus = (p['status'] as int?) ?? 0;
_otherSeenTime = p['seen'] as int?;
}
void _recomputeHeaderStatus() {
if (_commentsMode) {
_headerStatusNotifier.value = '';
return;
}
_headerStatusNotifier.value = _headerStatus();
}
int get _memberCount =>
ChatMembersStore.instance.count(widget.chatId) ??
chat?.participants.length ??
0;
bool get _isGroupChat =>
widget.chatType == 'CHAT' || widget.chatType == 'CHANNEL';
String _headerStatus() {
final l10n = AppLocalizations.of(context)!;
final conn = connectionStatusLabel(l10n, api.state);
if (conn != null) return conn;
final activity = ChatActivityStore.instance.snapshot(widget.chatId);
if (activity != null) {
return chatActivityLabel(l10n, activity, withNames: _isGroupChat);
}
if (widget.chatType == 'CHAT') {
return l10n.chatScreenMembersCount(_memberCount);
}
if (widget.chatType == 'CHANNEL') {
return l10n.chatScreenSubscribersCount(_memberCount);
}
if (_otherStatus == 1) return l10n.contactProfileOnline;
if (_otherStatus == 2 || _otherStatus == 3) {
return l10n.contactProfileRecentlyActive;
}
final s = _otherSeenTime;
if (s != null && s > 0) return formatLastSeen(l10n, s);
return '';
}
void _onTyping(Packet packet) {
final payload = packet.payload;
if (payload is! Map) return;
if (payload['chatId'] != widget.chatId) return;
final userId = payload['userId'];
if (userId is! int || userId == _myId) return;
ChatActivityStore.instance.mark(
widget.chatId,
userId,
chatActivityFromType(payload['type']),
);
unawaited(_ensureTypingName(userId));
}
Future<void> _ensureTypingName(int userId) async {
if (!_isGroupChat) return;
if (ContactCache.get(userId) != null) return;
final resolved = await messagesModule.ensureContactNames({userId});
if (resolved && mounted) _recomputeHeaderStatus();
}
void _clearTyping(int userId) {
ChatActivityStore.instance.clearUser(widget.chatId, userId);
}
void _onMessageRead(Packet packet) {
final payload = packet.payload;
if (payload is! Map) return;
if (payload['chatId'] != widget.chatId) return;
final userId = payload['userId'];
if (userId is! int || userId == _myId) return;
final mark = payload['mark'];
if (mark is! int) return;
if (payload['setAsUnread'] == true) return;
final c = chat;
if (c == null) return;
if (c.participants[userId] == mark) return;
c.participants[userId] = mark;
_syncOtherReadTime();
}
static String _formatLabel(AppLocalizations l10n, TextFormat format) {
switch (format) {
case TextFormat.heading:
return l10n.chatScreenFormatHeading;
case TextFormat.strong:
return l10n.chatScreenFormatBold;
case TextFormat.emphasized:
return l10n.chatScreenFormatItalic;
case TextFormat.underline:
return l10n.chatScreenFormatUnderline;
case TextFormat.strikethrough:
return l10n.chatScreenFormatStrikethrough;
case TextFormat.monospaced:
return l10n.chatScreenFormatMonospace;
case TextFormat.quote:
return l10n.chatScreenFormatQuote;
case TextFormat.link:
return l10n.contactProfileInfoLink;
case TextFormat.animoji:
return 'Animoji';
case TextFormat.userMention:
return l10n.chatScreenFormatMention;
}
}
Widget _formatContextMenu(
RichMessageController controller,
BuildContext context,
EditableTextState editableState, {
ContextMenuButtonItem? pasteItem,
}) {
final selection = controller.selection;
final buttonItems = <ContextMenuButtonItem>[];
if (selection.isValid && !selection.isCollapsed) {
final l10n = AppLocalizations.of(context)!;
for (final format in composerFormats) {
final active = controller.isFormatActive(format);
buttonItems.add(
ContextMenuButtonItem(
label: '${active ? '✓ ' : ''}${_formatLabel(l10n, format)}',
onPressed: () {
controller.toggleFormat(format);
editableState.hideToolbar();
},
),
);
}
}
if (pasteItem != null) buttonItems.add(pasteItem);
buttonItems.addAll(editableState.contextMenuButtonItems);
if (pasteItem != null) {
return PasteMediaToolbar(
anchors: editableState.contextMenuAnchors,
buttonItems: buttonItems,
pasteItem: pasteItem,
);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableState.contextMenuAnchors,
buttonItems: buttonItems,
);
}
static bool _sameElements(
List<Map<String, dynamic>> a,
List<Map<String, dynamic>> b,
) {
if (a.length != b.length) return false;
String canon(List<Map<String, dynamic>> els) {
final copy = [...els]
..sort((x, y) {
final t = (x['type'] as String).compareTo(y['type'] as String);
return t != 0 ? t : (x['from'] as int).compareTo(y['from'] as int);
});
return copy
.map((e) => '${e['type']}:${e['from']}:${e['length']}')
.join(',');
}
return canon(a) == canon(b);
}
int? _resolveOtherId() {
if (widget.chatType != 'DIALOG' || _myId == 0) return null;
if (widget.chatId == 0) return null;
final id = widget.chatId ^ _myId;
return id > 0 ? id : null;
}
int _complaintTypeId(String type) {
switch (type) {
case 'CHANNEL':
return 5;
case 'CHAT':
return 4;
default:
return 3;
}
}
Future<List<({int id, String title})>> _loadReportReasons(int typeId) async {
final reasons = await ComplaintsModule.reasonsFor(api, typeId);
return reasons.map((r) => (id: r.reasonId, title: r.reasonTitle)).toList();
}
Future<bool> _reportMessage(
CachedMessage message,
int typeId,
int reasonId,
) async {
final messageIdNum = int.tryParse(message.id);
if (messageIdNum == null) {
if (mounted) {
showCustomNotification(
context,
AppLocalizations.of(context)!.chatInfoComplaintFailed,
);
}
return false;
}
final ok = await ComplaintsModule.sendComplaint(
api,
reasonId: reasonId,
typeId: typeId,
ids: [messageIdNum],
parentId: widget.chatId,
);
if (!mounted) return ok;
showCustomNotification(
context,
ok
? AppLocalizations.of(context)!.chatInfoComplaintSent
: AppLocalizations.of(context)!.chatInfoComplaintFailed,
);
return ok;
}
CachedMessage _replaceMessage(
int index, {
String? id,
String? text,
String? status,
}) {
final old = _messages[index];
final updated = CachedMessage(
id: id ?? old.id,
accountId: old.accountId,
chatId: old.chatId,
senderId: old.senderId,
text: text ?? old.text,
time: old.time,
status: status ?? old.status,
payload: old.payload,
attachments: old.attachments,
isControl: old.isControl,
editHistory: old.editHistory,
sealedText: old.sealedText,
e2ee: old.e2ee,
);
_messages[index] = updated;
_bumpMessages();
return updated;
}
Future<String> _postCommandMessage(String text) async {
if (_encryptionEnabled) return _postPluginMessage(text);
if (!mounted || _myId == 0) return '';
final tempId = _nextTempId();
final now = DateTime.now().millisecondsSinceEpoch;
final online = api.state == SessionState.online;
final composed = CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: online ? 'sending' : 'pending',
);
_messages.add(composed);
_bumpMessages();
_scrollToBottom();
unawaited(_persistOutgoing(composed));
unawaited(
chats.applyOutgoing(
_myId,
widget.chatId,
messageId: tempId,
time: now,
text: text,
status: composed.status ?? 'sending',
),
);
if (!online) return tempId;
try {
final actualId = await messagesModule.sendMessage(
_myId,
widget.chatId,
text,
);
final realId = actualId.isNotEmpty ? actualId : tempId;
final i = _messages.indexWhere((m) => m.id == tempId);
if (i != -1) {
final sent = _replaceMessage(i, id: realId, status: 'sent');
unawaited(_persistOutgoing(sent, removeId: tempId));
unawaited(
chats.applyOutgoing(
_myId,
widget.chatId,
messageId: realId,
time: now,
text: text,
status: 'sent',
),
);
}
return realId;
} catch (_) {
return tempId;
}
}
Future<void> _updateCommandMessage(String id, String text) async {
if (_encryptionEnabled) return _editPluginMessage(id, text);
if (id.isEmpty) return;
final i = _messages.indexWhere((m) => m.id == id);
if (i != -1) {
final edited = _replaceMessage(i, text: text, status: 'EDITED');
unawaited(_persistOutgoing(edited));
}
if (!id.startsWith('temp_')) {
await messagesModule.editMessage(widget.chatId, id, text: text);
}
}
CommandContext _commandContext(String args) => CommandContext(
accountId: _myId,
chatId: widget.chatId,
otherUserId: _resolveOtherId(),
args: args,
messages: messagesModule,
isOnline: () => api.state == SessionState.online,
isActive: () => mounted,
notify: (message, {duration}) {
if (mounted) showCustomNotification(context, message, duration: duration);
},
postMessage: _postCommandMessage,
updateMessage: _updateCommandMessage,
sendPhotos: _sendPhotos,
sendVideoNote: _sendVideoNote,
runPlugin: (command) => _executePlugin(command, args),
);
Future<void> _scheduleMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty || _myId == 0) return;
final when = await _pickScheduleTime();
if (when == null || !mounted) return;
final wireText = await _encryptOutgoing(text);
if (wireText == null || !mounted) return;
try {
await messagesModule.sendMessage(
_myId,
widget.chatId,
wireText,
scheduledTime: when.millisecondsSinceEpoch,
);
if (!mounted) return;
_hasText.value = false;
_messageController.clear();
Haptics.send();
_markHasScheduled();
final l10n = AppLocalizations.of(context)!;
showCustomNotification(
context,
l10n.chatScreenScheduledFor(formatDateTimeWords(l10n, when)),
);
} catch (_) {
if (!mounted) return;
Haptics.error();
showCustomNotification(
context,
AppLocalizations.of(context)!.chatScreenScheduleFailed,
);
}
}
Future<DateTime?> _pickScheduleTime() => showScheduleTimePicker(context);
void _openScheduledMessages() {
Navigator.of(context)
.push(
MaterialPageRoute(
builder: (_) => ScheduledMessagesScreen(
chatId: widget.chatId,
accountId: _myId,
chatName: _chatName,
),
),
)
.then((_) {
if (mounted) _refreshScheduledCount();
});
}
Future<void> _persistOutgoing(CachedMessage msg, {String? removeId}) async {
try {
await AppDatabase.saveOutgoingMessage(
msg.toDbRow(),
removeId: removeId,
createDialog: !_commentsMode && widget.chatType == 'DIALOG',
dialogTitle: _chatName,
dialogIconUrl: _chatImageUrl,
);
} catch (error) {
logger.e('Message persistence failed: $error');
if (mounted) {
showCustomNotification(context, 'Не удалось сохранить сообщение');
}
}
}
Future<void> _loadGroupSenderNames() async {
if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return;
final unknownIds = <int>{};
for (final msg in _messages) {
if (msg.isControl) continue;
final id = msg.senderId;
if (id == 0 || id == _myId) continue;
if (ContactCache.get(id) == null) unknownIds.add(id);
}
if (unknownIds.isEmpty) return;
final resolved = await messagesModule.ensureContactNames(unknownIds);
if (resolved && mounted) _bumpMessages();
}
Future<void> _loadForwardedSenderNames() async {
final forwardIds = <int>{};
for (final msg in _messages) {
if (msg.attachments != null) {
for (final a in msg.attachments!) {
if (a is ForwardedMessageAttachment) {
if (a.originalSenderId != 0 &&
a.originalSenderName == null &&
ContactCache.get(a.originalSenderId) == null) {
forwardIds.add(a.originalSenderId);
}
}
}
}
}
if (forwardIds.isEmpty) return;
final resolved = <int, ({String name, String? avatar})>{};
for (final id in forwardIds) {
final name = await messagesModule.searchContactById(id);
if (name != null) {
resolved[id] = (name: name, avatar: ContactCache.getAvatar(id));
}
}
if (resolved.isEmpty || !mounted) return;
var anyChanged = false;
for (var i = 0; i < _messages.length; i++) {
final msg = _messages[i];
final attaches = msg.attachments;
if (attaches == null) continue;
var msgChanged = false;
final newAttaches = attaches.map((a) {
if (a is ForwardedMessageAttachment &&
a.originalSenderName == null &&
resolved.containsKey(a.originalSenderId)) {
final r = resolved[a.originalSenderId]!;
msgChanged = true;
return ForwardedMessageAttachment(
originalSenderId: a.originalSenderId,
originalSenderName: r.name,
originalSenderAvatar: r.avatar,
originalType: a.originalType,
originalMessageId: a.originalMessageId,
originalTime: a.originalTime,
originalText: a.originalText,
originalChatId: a.originalChatId,
originalChatAccess: a.originalChatAccess,
originalFormatRanges: a.originalFormatRanges,
originalAttachments: a.originalAttachments,
originalContact: a.originalContact,
);
}
return a;
}).toList();
if (!msgChanged) continue;
anyChanged = true;
_messages[i] = msg.copyWith(attachments: newAttaches);
}
if (anyChanged) {
_bumpMessages();
}
}
void _scrollToBottom() {
_flushDeferredMessages();
_returnStack.clear();
_newMessageCount.value = 0;
_syncUnreadMessageCount();
if (_scrollAnchorId != null) {
_scrollAnchorId = null;
_listEpoch++;
_bumpMessages();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) return;
final pos = _scrollController.position;
final runway = pos.viewportDimension;
final teleport =
AnchoredMessageList.distanceFromBottom(pos) >
runway * _scrollDownTeleportFactor;
if (teleport) {
_pinnedMessageId = null;
_listEpoch++;
_jumpCacheExtent.value = _jumpCacheExtentPx;
_bumpMessages();
_scrollController.jumpTo(runway);
}
unawaited(
_scrollController
.animateTo(
pos.minScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.whenComplete(() {
if (!teleport) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _jumpCacheExtent.value = null;
});
}),
);
});
}
void _updateScrollDownVisible() {
if (!_scrollController.hasClients) {
_setScrollDownVisible(_unreadMessageCount.value > 0);
return;
}
final pos = _scrollController.position;
final atBottom = _isNearBottom();
if (_returnStack.isNotEmpty &&
atBottom &&
pos.userScrollDirection != ScrollDirection.idle) {
_returnStack.clear();
}
if (atBottom && (_newMessageCount.value > 0 || _deferredIds.isNotEmpty)) {
_clearNewMessageCountSoon();
}
final reveal = math.min(
_scrollDownRevealExtent,
pos.viewportDimension * _scrollDownRevealFactor,
);
_setScrollDownVisible(
AnchoredMessageList.distanceFromBottom(pos) >= reveal ||
(_pinnedMessageId != null && !atBottom) ||
_returnStack.isNotEmpty ||
_unreadMessageCount.value > 0,
);
}
void _setScrollDownVisible(bool show) {
if (show == _scrollDownVisible) return;
_scrollDownVisible = show;
if (show) {
_scrollDownAnimController.forward();
} else {
_scrollDownAnimController.reverse();
}
}
void _noteMissedMessage() {
_newMessageCount.value++;
_syncUnreadMessageCount();
}
void _syncUnreadMessageCount({CachedChat? snapshot}) {
final current = snapshot ?? chat;
_unreadMessageCount.value = _commentsMode
? _newMessageCount.value
: countUnreadMessages(
chat: current,
messages: _messages,
accountId: _myId,
readTime: _readMarkTime,
newestLoaded:
_messages.isNotEmpty &&
_messages.last.time >= (current?.lastMsgTime ?? 0),
);
_updateScrollDownVisible();
}
void _clearNewMessageCountSoon() {
if (_clearCountScheduled) return;
_clearCountScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_clearCountScheduled = false;
if (!mounted || !_isNearBottom()) return;
_flushDeferredMessages();
_newMessageCount.value = 0;
_syncUnreadMessageCount();
});
}
void _pushReturnAnchor(String messageId) {
if (!_scrollController.hasClients) return;
final listBox = _listKey.currentContext?.findRenderObject();
final dy = _messageOffsetInList(messageId);
final viewportH = listBox is RenderBox ? listBox.size.height : 0.0;
final alignment = viewportH > 0 && dy != null
? (dy / viewportH).clamp(0.0, 1.0)
: 0.5;
_returnStack.add((
id: messageId,
pixels: _scrollController.position.pixels,
alignment: alignment.toDouble(),
));
if (!_scrollDownVisible) {
_scrollDownVisible = true;
_scrollDownAnimController.forward();
}
}
void _onScrollDownTap() {
if (_returningToAnchor || _navigatingToTarget) return;
if (!_scrollController.hasClients) {
_scrollToBottom();
return;
}
final pixels = _scrollController.position.pixels;
while (_returnStack.isNotEmpty) {
final anchor = _returnStack.removeLast();
if (anchor.pixels < pixels && _messages.any((m) => m.id == anchor.id)) {
_returningToAnchor = true;
unawaited(
_returnToAnchor(
anchor,
).whenComplete(() => _returningToAnchor = false),
);
return;
}
}
_scrollToBottom();
}
Future<void> _returnToAnchor(
({String id, double pixels, double alignment}) anchor,
) async {
final pos = _scrollController.position;
final runway = pos.viewportDimension;
final target = anchor.pixels.clamp(
pos.minScrollExtent,
pos.maxScrollExtent,
);
final distance = (pos.pixels - target).abs();
final far = distance > runway * _scrollDownTeleportFactor;
if (!far) {
await _scrollController.animateTo(
target,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
if (!mounted) return;
await _scrollToMessagePrecise(anchor.id, alignment: anchor.alignment);
return;
}
_jumpCacheExtent.value = _jumpCacheExtentPx;
if (target + runway < distance) {
_listEpoch++;
_bumpMessages();
_scrollController.jumpTo(target + runway);
await _scrollController.animateTo(
target,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
if (!mounted) return;
}
await _scrollToMessagePrecise(anchor.id, alignment: anchor.alignment);
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _jumpCacheExtent.value = null;
});
}
bool _isNearBottom() {
if (!_scrollController.hasClients) return true;
return AnchoredMessageList.distanceFromBottom(_scrollController.position) <=
120;
}
void _startReply(CachedMessage message) {
_cancelForward();
_replyTo.value = message;
_replySourceChatId = null;
_messageFocusNode.requestFocus();
}
void _cancelReply() {
_replyTo.value = null;
_replySourceChatId = null;
}
Future<void> _pickReplyChat() async {
final reply = _replyTo.value;
if (reply == null) return;
if (reply.id.startsWith('temp_')) {
showCustomNotification(context, 'Сообщение ещё не отправлено');
return;
}
final sourceChatId = _replySourceChatId ?? widget.chatId;
final target = await openForwardScreen(context: context);
if (target == null || !mounted) return;
if (target.chatId == widget.chatId) {
_replySourceChatId = sourceChatId == widget.chatId ? null : sourceChatId;
_messageFocusNode.requestFocus();
return;
}
await chats.ensureChatCached(api, _myId, target.chatId);
if (!mounted) return;
pushSwipeable(
context,
(_) => ChatScreen(
chatId: target.chatId,
name: target.name,
imageUrl: target.imageUrl,
chatType: target.chatType,
replyRequest: ReplyRequest(sourceChatId: sourceChatId, message: reply),
),
);
}
void _openSenderProfile(int senderId) {
if (senderId == 0 || senderId == _myId) return;
unawaited(
openContactDialogProfile(
context,
contactId: senderId,
name: ContactCache.get(senderId) ?? 'User #$senderId',
avatarUrl: ContactCache.getAvatar(senderId),
),
);
}
void _openForwardedSource(ForwardedMessageAttachment forwarded) {
if (forwarded.isChannel) {
unawaited(_openForwardedChannel(forwarded));
return;
}
final senderId = forwarded.originalSenderId;
if (senderId == 0 || senderId == _myId) return;
unawaited(
openContactDialogProfile(
context,
contactId: senderId,
name:
forwarded.originalSenderName ??
ContactCache.get(senderId) ??
'User #$senderId',
avatarUrl:
forwarded.originalSenderAvatar ?? ContactCache.getAvatar(senderId),
),
);
}
Future<void> _openForwardedChannel(
ForwardedMessageAttachment forwarded,
) async {
final sourceChatId = forwarded.originalChatId;
if (sourceChatId == null) {
showCustomNotification(
context,
AppLocalizations.of(context)!.chatScreenChannelUnavailable,
);
return;
}
final sourceMessageId = forwarded.originalMessageId;
if (sourceChatId == widget.chatId) {
if (sourceMessageId == null) return;
_beginTargetNavigation();
await _runGoToMessage(sourceMessageId, forwarded.originalTime ?? 0);
return;
}
if (!await chats.canOpenForwardSource(api, _myId, forwarded)) {
if (!mounted) return;
await showNoChatAccessCard(context);
return;
}
if (!mounted) return;
await chats.ensureChatCached(api, _myId, sourceChatId);
if (!mounted) return;
final cached = await chats.getChat(_myId, sourceChatId);
if (!mounted) return;
final channel = cached.isEmpty ? null : cached.first;
final fallbackName = AppLocalizations.of(
context,
)!.chatScreenChannelFallback;
pushSwipeable(
context,
(_) => ChatScreen(
chatId: sourceChatId,
name: channel?.title ?? forwarded.originalSenderName ?? fallbackName,
imageUrl: channel?.iconUrl ?? forwarded.originalSenderAvatar ?? '',
chatType: channel?.type ?? 'CHANNEL',
initialMessageId: sourceMessageId,
initialMessageTime: forwarded.originalTime,
),
);
}
void _openStickerPack(StickerAttachment sticker) {
final stickerId = int.tryParse(sticker.stickerId ?? '');
if (stickerId == null) {
showCustomNotification(
context,
AppLocalizations.of(context)!.stickerPackSheetUnavailable,
);
return;
}
showStickerPackSheet(
context,
stickerId: stickerId,
knownSetId: int.tryParse(sticker.stickerPackId ?? ''),
);
}
void _jumpToMessage(String messageId, {String? fromId}) {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) {
showCustomNotification(context, 'Сообщение не загружено');
return;
}
if (fromId != null) _pushReturnAnchor(fromId);
final key = _keyForMessage(messageId);
final ctx = key.currentContext;
if (ctx != null) {
Scrollable.ensureVisible(
ctx,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
alignment: 0.4,
);
} else {
unawaited(_scrollToMessagePrecise(messageId, alignment: 0.4));
}
_highlightTimer?.cancel();
_highlightMessageId.value = messageId;
_highlightTimer = Timer(const Duration(milliseconds: 1400), () {
if (!mounted) return;
if (_highlightMessageId.value == messageId) {
_highlightMessageId.value = null;
}
});
}
final Map<String, GlobalKey> _messageKeys = {};
GlobalKey _keyForMessage(String messageId) =>
_messageKeys.putIfAbsent(messageId, () => GlobalKey());
void _openSearch() {
if (_search.searchMode.value || _selectionMode) return;
// #***! запрос уходит на сервер, а сервер видит только шифртекст
if (_encryptionEnabled) {
showCustomNotification(
context,
AppLocalizations.of(context)!.e2eeSearchBlocked,
);
return;
}
_search.searchMode.value = true;
_searchAnim.forward();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _search.searchMode.value) _searchFocusNode.requestFocus();
});
}
void _closeSearch() {
if (!_search.searchMode.value) return;
_searchFocusNode.unfocus();
_searchAnim.reverse();
_search.reset();
}
Future<void> _navigateToInitialMessage() async {
final id = widget.initialMessageId;
if (id == null) {
_finishTargetNavigation();
return;
}
await _runGoToMessage(id, widget.initialMessageTime ?? 0);
}
Future<void> _runGoToMessage(String id, int targetTime) async {
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
if (!_messages.any((m) => m.id == id)) {
await _loadMessageWindow(id, targetTime);
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();
}
({int min, int max})? _laidOutMessageRange(List<Object> items) {
int? lo;
int? hi;
for (var i = 0; i < items.length; i++) {
final it = items[i];
if (it is! _MessageItem) continue;
final ro = _keyForMessage(
it.message.id,
).currentContext?.findRenderObject();
if (ro is RenderBox && ro.attached) {
lo ??= i;
hi = i;
}
}
if (lo == null) return null;
return (min: lo, max: hi!);
}
Future<void> _scrollToMessagePrecise(
String id, {
double alignment = 0.32,
}) async {
if (!mounted || !_scrollController.hasClients) return;
if (_messages.indexWhere((m) => m.id == id) == -1) return;
final epoch = _userGestureEpoch;
_historyAutoloadSuppressCount++;
_holdReadMarker();
try {
final initialBox = _keyForMessage(id).currentContext?.findRenderObject();
if (initialBox is! RenderBox || !initialBox.attached) {
_anchorLoadedMessage(id, alignment);
await WidgetsBinding.instance.endOfFrame;
}
var stable = 0;
for (var iter = 0; iter < 120; iter++) {
if (!mounted || !_scrollController.hasClients) return;
if (_userGestureEpoch != epoch) return;
final listObj = _listKey.currentContext?.findRenderObject();
final boxObj = _keyForMessage(id).currentContext?.findRenderObject();
final p = _scrollController.position;
if (listObj is RenderBox && boxObj is RenderBox && boxObj.attached) {
final viewportH = listObj.size.height;
final actualTop = boxObj
.localToGlobal(Offset.zero, ancestor: listObj)
.dy;
final desiredTop = alignment * viewportH;
final delta = desiredTop - actualTop;
final target = (p.pixels + delta).clamp(
p.minScrollExtent,
p.maxScrollExtent,
);
if (delta.abs() <= 2.0 || (target - p.pixels).abs() <= 1.0) {
stable++;
if (stable >= 4) return;
await Future.delayed(const Duration(milliseconds: 60));
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;
final viewportH = listObj is RenderBox ? listObj.size.height : 600.0;
var stepMag = viewportH * 0.8;
if (stepMag > 700) stepMag = 700;
final range = _laidOutMessageRange(items);
final step = (range != null && pos > range.max) ? -stepMag : stepMag;
final target = (p.pixels + step).clamp(
p.minScrollExtent,
p.maxScrollExtent,
);
if ((target - p.pixels).abs() < 1.0) return;
_scrollController.jumpTo(target);
await WidgetsBinding.instance.endOfFrame;
}
} finally {
_historyAutoloadSuppressCount--;
_releaseReadMarker();
}
}
Future<void> _openSearchResult(MessageSearchResult result) async {
_closeSearch();
if (_messages.any((m) => m.id == result.id)) {
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
_scrollToLoadedMessage(result.id);
return;
}
setState(_beginTargetNavigation);
await _runGoToMessage(result.id, result.time);
}
void _scrollToLoadedMessage(
String messageId, {
double alignment = 0.4,
bool highlight = true,
bool notifyIfMissing = true,
VoidCallback? onSettled,
}) {
void settle() {
_releaseReadMarker();
onSettled?.call();
}
_holdReadMarker();
if (!_scrollController.hasClients) {
settle();
return;
}
if (_deferredIds.contains(messageId)) _flushDeferredMessages();
final items = _buildCombinedItems();
final pos = items.indexWhere(
(it) => it is _MessageItem && it.message.id == messageId,
);
if (pos == -1) {
if (notifyIfMissing) {
showCustomNotification(context, 'Сообщение не загружено');
}
settle();
return;
}
final laidOut = _keyForMessage(
messageId,
).currentContext?.findRenderObject();
if (laidOut is! RenderBox || !laidOut.attached) {
_anchorLoadedMessage(messageId, alignment);
}
if (highlight) {
_highlightTimer?.cancel();
_highlightMessageId.value = messageId;
_highlightTimer = Timer(const Duration(milliseconds: 1600), () {
if (!mounted) return;
if (_highlightMessageId.value == messageId) {
_highlightMessageId.value = null;
}
});
}
WidgetsBinding.instance.addPostFrameCallback(
(_) => _alignLoadedMessage(messageId, alignment, 0, onSettled: settle),
);
}
({int oldest, int newest}) _visibleItemRange(
List<Object> items,
RenderBox listBox,
) {
var oldest = -1;
var newest = -1;
final viewportBottom = listBox.size.height;
for (var i = 0; i < items.length; i++) {
final item = items[i];
if (item is! _MessageItem) continue;
final box = _messageKeys[item.message.id]?.currentContext
?.findRenderObject();
if (box is! RenderBox || !box.attached) {
if (oldest != -1) break;
continue;
}
final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy;
if (top + box.size.height <= 0 || top >= viewportBottom) {
if (oldest != -1) break;
continue;
}
if (oldest == -1) oldest = i;
newest = i;
}
return (oldest: oldest, newest: newest);
}
double _jumpStepScreens(int index, ({int oldest, int newest}) visible) {
if (visible.oldest == -1) return 1;
final perScreen = visible.newest - visible.oldest + 1;
if (perScreen <= 0) return 1;
final away = index < visible.oldest
? visible.oldest - index
: index - visible.newest;
return (away / perScreen).clamp(1.0, _jumpStepMaxScreens);
}
bool _jumpNearMessage(String messageId) {
if (!_scrollController.hasClients) return false;
final listBox = _listKey.currentContext?.findRenderObject();
if (listBox is! RenderBox || listBox.size.height <= 0) return false;
final items = _buildCombinedItems();
final index = items.indexWhere(
(it) => it is _MessageItem && it.message.id == messageId,
);
if (index == -1) return false;
final visible = _visibleItemRange(items, listBox);
final position = _scrollController.position;
final step = position.viewportDimension * _jumpStepScreens(index, visible);
final double next;
if (visible.oldest == -1 || index < visible.oldest) {
next = position.pixels + step;
} else if (index > visible.newest) {
next = position.pixels - step;
} else {
return false;
}
final clamped = next.clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
if ((clamped - position.pixels).abs() < 0.5) return false;
_scrollController.jumpTo(clamped);
return true;
}
int? _anchoredItemIndex(List<Object> items) {
final id = _scrollAnchorId;
if (id == null) return null;
final index = items.indexWhere(
(item) => item is _MessageItem && item.message.id == id,
);
return index < 0 ? null : index;
}
void _anchorLoadedMessage(String id, double alignment) {
if (!_scrollController.hasClients) return;
if (_scrollAnchorId != id) {
_scrollAnchorId = id;
_listEpoch++;
_bumpMessages();
}
final position = _scrollController.position;
final box = _messageKeys[id]?.currentContext?.findRenderObject();
final height = box is RenderBox && box.hasSize ? box.size.height : 0.0;
_historyAutoloadSuppressCount++;
_scrollController.jumpTo(
AnchoredMessageList.anchoredPixels(
alignment: alignment,
viewport: position.viewportDimension,
anchorHeight: height,
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
_historyAutoloadSuppressCount--;
if (mounted) _updateScrollDownVisible();
});
}
void _alignLoadedMessage(
String messageId,
double alignment,
int attempt, {
int frames = 0,
int? epoch,
VoidCallback? onSettled,
}) {
if (!mounted ||
!_scrollController.hasClients ||
(epoch != null && epoch != _userGestureEpoch)) {
onSettled?.call();
return;
}
final listBox = _listKey.currentContext?.findRenderObject();
final box = _keyForMessage(messageId).currentContext?.findRenderObject();
if (listBox is! RenderBox || box is! RenderBox || !box.attached) {
if (attempt >= _jumpStallLimit || frames >= _jumpFrameLimit) {
onSettled?.call();
return;
}
final moved = _jumpNearMessage(messageId);
WidgetsBinding.instance.addPostFrameCallback(
(_) => _alignLoadedMessage(
messageId,
alignment,
moved ? 0 : attempt + 1,
frames: frames + 1,
epoch: epoch,
onSettled: onSettled,
),
);
return;
}
final viewportHeight = listBox.size.height;
final actualTop = box.localToGlobal(Offset.zero, ancestor: listBox).dy;
final desiredTop = alignment.clamp(0.0, 1.0) * viewportHeight;
final delta = desiredTop - actualTop;
final pos = _scrollController.position;
final target = (pos.pixels + delta).clamp(
pos.minScrollExtent,
pos.maxScrollExtent,
);
if (viewportHeight <= 0 ||
delta.abs() <= 0.5 ||
(target - pos.pixels).abs() <= 0.5 ||
attempt >= _jumpStallLimit ||
frames >= _jumpFrameLimit) {
onSettled?.call();
return;
}
_scrollController.jumpTo(target);
WidgetsBinding.instance.addPostFrameCallback(
(_) => _alignLoadedMessage(
messageId,
alignment,
attempt + 1,
frames: frames + 1,
epoch: epoch,
onSettled: onSettled,
),
);
}
String _searchSenderName(int senderId) {
final l10n = AppLocalizations.of(context)!;
if (senderId == _myId) return l10n.playbackPillYou;
final cached = ContactCache.get(senderId);
if (cached != null && cached.isNotEmpty) return cached;
if (widget.chatType == 'DIALOG') return widget.name;
return l10n.msgActionsReadByUnknownUser;
}
String? _searchSenderAvatar(int senderId) {
final cached = ContactCache.getAvatar(senderId);
if (cached != null && cached.isNotEmpty) return cached;
if (senderId != _myId &&
widget.chatType == 'DIALOG' &&
widget.imageUrl.isNotEmpty) {
return widget.imageUrl;
}
return null;
}
int _firstUnreadIndex() {
final anchor = _unreadAnchorTime;
if (anchor == null) return -1;
return _messages.indexWhere((m) => m.time > anchor);
}
List<Object> _buildCombinedItems() {
final visible = _visibleMessageCount;
final key = Object.hash(
_messagesRev.value,
_messages.length,
visible,
_unreadAnchorTime,
);
final cached = _combinedItemsCache;
if (cached != null && _combinedItemsKey == key) return cached;
final unreadIndex = _firstUnreadIndex();
final List<Object> items = [];
final Set<int> usedDates = {};
for (int i = 0; i < visible; i++) {
final msg = _messages[i];
final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time);
final dayMillis = DateTime(
msgDate.year,
msgDate.month,
msgDate.day,
).millisecondsSinceEpoch;
bool needSeparator = i == 0;
if (!needSeparator) {
final prevDate = DateTime.fromMillisecondsSinceEpoch(
_messages[i - 1].time,
);
final prevDayMillis = DateTime(
prevDate.year,
prevDate.month,
prevDate.day,
).millisecondsSinceEpoch;
needSeparator = dayMillis != prevDayMillis;
}
if (needSeparator) {
_separatorKeys.putIfAbsent(dayMillis, () => GlobalKey());
usedDates.add(dayMillis);
items.add(
_DateSeparatorItem(
DateTime.fromMillisecondsSinceEpoch(dayMillis),
_separatorKeys[dayMillis]!,
),
);
}
if (i == unreadIndex) {
items.add(const _UnreadSeparatorItem());
}
items.add(_MessageItem(msg, i));
}
_separatorKeys.removeWhere((k, _) => !usedDates.contains(k));
_combinedItemsCache = items;
_combinedItemsKey = key;
return items;
}
void _onScrollForDate() {
if (!_scrollController.hasClients) return;
_floatingDateTimer?.cancel();
_floatingDateTimer = Timer(const Duration(seconds: 1), () {
if (mounted) _floatingDateAnimController.reverse();
});
if (_floatingDateScheduled) return;
_floatingDateScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_floatingDateScheduled = false;
_updateFloatingDate();
});
}
void _updateFloatingDate() {
if (!mounted || _separatorKeys.isEmpty) return;
DateTime? result;
final listRenderBox = _listKey.currentContext?.findRenderObject();
if (listRenderBox is! RenderBox) return;
_separatorKeys.forEach((dayMillis, gkey) {
final ctx = gkey.currentContext;
if (ctx == null) return;
final box = ctx.findRenderObject();
if (box is! RenderBox) return;
final pos = box.localToGlobal(Offset.zero, ancestor: listRenderBox);
if (pos.dy + box.size.height < 4) {
final date = DateTime.fromMillisecondsSinceEpoch(dayMillis);
if (result == null || date.isAfter(result!)) {
result = date;
}
}
});
if (result == null) return;
final bool dateChanged = result != _floatingDate.value;
_floatingDate.value = result;
if (dateChanged) {
_floatingDateAnimController.forward(from: 0);
} else {
_floatingDateAnimController.forward();
}
}
String _formatDateLabel(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final d = DateTime(date.year, date.month, date.day);
if (d == today) return 'Сегодня';
if (d == yesterday) return 'Вчера';
const months = [
'января',
'февраля',
'марта',
'апреля',
'мая',
'июня',
'июля',
'августа',
'сентября',
'октября',
'ноября',
'декабря',
];
if (date.year == now.year) {
return '${date.day} ${months[date.month - 1]}';
}
return '${date.day} ${months[date.month - 1]} ${date.year}';
}
Widget _buildDateSeparatorWidget(
BuildContext context,
DateTime date, {
Key? key,
bool floating = false,
}) {
final cs = Theme.of(context).colorScheme;
return Padding(
key: key,
padding: EdgeInsets.symmetric(vertical: floating ? 2 : 8),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_formatDateLabel(date),
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontStyle: floating ? FontStyle.normal : FontStyle.italic,
),
),
),
),
);
}
Widget _buildUnreadSeparatorWidget(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final accent = cs.primary;
return Padding(
key: _unreadSeparatorKey,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
child: Row(
children: [
Expanded(
child: Container(
height: 1.5,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(1),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'Непрочитанные сообщения',
style: TextStyle(
color: accent,
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
Expanded(
child: Container(
height: 1.5,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(1),
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = _prank.active
? _prank.pinkTheme(Theme.of(context))
: Theme.of(context);
final cs = theme.colorScheme;
final underlap = _effectiveChrome != ChatChromeStyle.color;
// TODO: Локализация
// TODO: Cклонения
final mq = MediaQuery.of(context);
final bottomInset = _keyboardReserve > 0
? math.max(mq.viewInsets.bottom, _keyboardReserve)
: mq.viewInsets.bottom;
return ListenableBuilder(
listenable: Listenable.merge([
_selectedIds,
_search.searchMode,
_textSelection,
]),
builder: (context, child) => PopScope(
canPop:
_selectedIds.value.isEmpty &&
!_search.searchMode.value &&
_textSelection.value == null,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (_search.searchMode.value) {
_closeSearch();
} else if (_textSelection.value != null) {
_exitTextSelection();
} else {
_clearSelection();
}
},
child: child!,
),
child: MediaQuery(
data: mq.copyWith(
viewInsets: mq.viewInsets.copyWith(bottom: bottomInset),
),
child: Theme(
data: theme,
child: RepaintBoundary(
key: _prank.captureKey,
child: ValueListenableBuilder<bool>(
valueListenable: AppSwipeBackDesktop.current,
builder: (context, desktopSwipe, child) => SwipeToPop(
enabled: widget.embedded && desktopSwipe,
onPop: widget.onClose,
child: child!,
),
child: AnimatedBuilder(
animation: _searchAnim,
child: LottieHoldScope(
isHeld: _animojiHold,
child: underlap ? _buildUnderlapBody() : _buildColorBody(),
),
builder: (context, body) => Scaffold(
backgroundColor: cs.surface,
extendBodyBehindAppBar: underlap,
appBar: _buildAppBar(cs),
body: body,
),
),
),
),
),
),
);
}
Widget _buildPinnedAndPill() {
return ValueListenableBuilder<PlaybackKind?>(
valueListenable: MediaPlayback.instance.primary,
builder: (context, kind, _) {
final merged = kind != null;
final banner = _buildPinnedBanner(
floating: true,
borderRadius: merged
? const BorderRadius.vertical(top: Radius.circular(16))
: null,
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
?banner,
MediaPlaybackPill(
borderRadius: banner == null
? BorderRadius.circular(16)
: const BorderRadius.vertical(bottom: Radius.circular(16)),
),
],
);
},
);
}
Widget? _buildPinnedBanner({
required bool floating,
BorderRadius? borderRadius,
}) {
final pinned = chat;
if (pinned == null || !pinned.hasPinnedMessage) return null;
return _PinnedMessageBanner(
text: pinned.pinnedMsgText,
isPreview: pinned.pinnedMsgIsPreview,
floating: floating,
borderRadius: borderRadius,
frosted: _effectiveChrome == ChatChromeStyle.transparent,
liquid: _liquidChrome,
backdropKey: _pillBackdrop,
onTap: _jumpToPinnedMessage,
onUnpin: _canPin ? () => unawaited(_unpinCurrentMessage()) : null,
);
}
Widget? _buildCallBanner({required bool floating}) {
final call = chat?.activeCall;
if (call == null || _commentsMode) return null;
return ChatCallBanner(
call: call,
onJoin: _joinChatCall,
floating: floating,
frosted: _effectiveChrome == ChatChromeStyle.transparent,
liquid: _liquidChrome,
backdropKey: _pillBackdrop,
);
}
Widget _buildColorBody() {
final cs = Theme.of(context).colorScheme;
final banner = _buildPinnedBanner(floating: false);
final frosted = _composerFrosted;
final composer = _MeasureSize(
onHeight: (value) => _composerHeight.value = value,
child: _buildComposerArea(context),
);
return Column(
children: [
?_buildCallBanner(floating: false),
?banner,
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
if (_wallpaper != null)
Positioned.fill(
child: ChatWallpaperView(wallpaper: _wallpaper!),
),
Positioned.fill(child: _buildMessagesArea()),
ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) => Positioned(
left: 0,
right: 0,
bottom: frosted ? height : 0,
child: TextFieldTapRegion(
enabled: IosRelease.isIOS,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MentionPanelView(mentionPanel: _mentionPanel),
CommandPanelView(commandPanel: _commandPanel),
],
),
),
),
),
VideoNoteRecordingLayer(controller: _note),
if (frosted)
Positioned(left: 0, right: 0, bottom: 0, child: composer),
SearchOverlay(
cs: cs,
searchAnim: _searchAnim,
search: _search,
onOpenResult: _openSearchResult,
senderName: _searchSenderName,
senderAvatar: _searchSenderAvatar,
),
],
),
),
if (!frosted) composer,
],
);
}
double _pinnedBannerTop() {
final glossy = AppVisualStyle.current.value.glossyChrome;
return MediaQuery.paddingOf(context).top +
(glossy ? _glossyHeaderHeight : kToolbarHeight) -
_pinnedBannerLift;
}
Widget _buildUnderlapBody() {
final cs = Theme.of(context).colorScheme;
final vignette = _chromeVignette;
final bannerTop = _pinnedBannerTop();
return Stack(
fit: StackFit.expand,
children: [
if (_wallpaper != null)
Positioned.fill(child: ChatWallpaperView(wallpaper: _wallpaper!)),
Positioned.fill(child: _buildMessagesArea()),
if (vignette) ...[
if (AppVisualStyle.current.value.glossyChrome)
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildEdgeVignette(cs, top: true),
),
ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) => _composerPaintsSurface
? Positioned(
left: 0,
right: 0,
bottom: height,
child: _buildEdgeFade(cs),
)
: Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildEdgeVignette(cs, top: false, height: height),
),
),
],
Positioned(
top: bannerTop,
left: 8,
right: 8,
child: _MeasureSize(
onHeight: (value) => _pinnedBannerHeight.value = value,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_showsCallBanner)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: _buildCallBanner(floating: true),
),
_buildPinnedAndPill(),
],
),
),
),
ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) => Positioned(
left: 0,
right: 0,
bottom: height,
child: TextFieldTapRegion(
enabled: IosRelease.isIOS,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MentionPanelView(mentionPanel: _mentionPanel),
CommandPanelView(commandPanel: _commandPanel),
],
),
),
),
),
VideoNoteRecordingLayer(controller: _note),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Builder(
builder: (context) => MediaQuery.removePadding(
context: context,
removeTop: true,
child: _MeasureSize(
onHeight: (value) => _composerHeight.value = value,
child: _buildComposerArea(context),
),
),
),
),
SearchOverlay(
cs: cs,
searchAnim: _searchAnim,
search: _search,
onOpenResult: _openSearchResult,
senderName: _searchSenderName,
senderAvatar: _searchSenderAvatar,
),
],
);
}
Widget _buildEdgeFade(ColorScheme cs) {
return IgnorePointer(
child: Container(
height: _edgeFadeHeight,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [cs.surface, cs.surface.withValues(alpha: 0.0)],
),
),
),
);
}
Widget _buildEdgeVignette(
ColorScheme cs, {
required bool top,
double? height,
}) {
final double resolved;
if (height != null) {
resolved = height;
} else {
final glossy = AppVisualStyle.current.value.glossyChrome;
resolved =
MediaQuery.paddingOf(context).top +
(glossy ? _glossyHeaderHeight : kToolbarHeight);
}
return IgnorePointer(
child: Container(
height: resolved,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: top ? Alignment.topCenter : Alignment.bottomCenter,
end: top ? Alignment.bottomCenter : Alignment.topCenter,
colors: [cs.surface, cs.surface.withValues(alpha: 0.0)],
),
),
),
);
}
Widget _buildMessagesArea() {
final showShimmer = _messages.isEmpty
? _isLoading
: (_awaitingPosition || _navigatingToTarget);
return Stack(
fit: StackFit.expand,
children: [
Opacity(
opacity: showShimmer ? 0.0 : 1.0,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollStartNotification &&
notification.dragDetails != null) {
_userGestureEpoch++;
} else if (notification is ScrollEndNotification) {
final metrics = notification.metrics;
if (!metrics.pixels.isFinite ||
!metrics.maxScrollExtent.isFinite) {
logger.w(
'[chat-scroll] invalid extent: pixels=${metrics.pixels} '
'max=${metrics.maxScrollExtent} messages=${_messages.length}',
);
}
_readMarker.flush();
}
return false;
},
child: _buildMessagesList(),
),
),
if (showShimmer)
Positioned.fill(child: ShimmerLoading(shimmer: _shimmerController)),
Positioned.fill(
child: ValueListenableBuilder<int>(
valueListenable: _messagesRev,
builder: (context, _, _) => _buildEmptyState(),
),
),
],
);
}
bool get _isPersonDialog =>
!_commentsMode && widget.chatType == 'DIALOG' && widget.chatId != 0;
Widget _buildEmptyState() {
final empty = _messages.isEmpty && !_isLoading;
final greetingDue =
empty && _isPersonDialog && _peerKindKnown && !_peerIsBot;
if (greetingDue) _greetingMounted = true;
if (_greetingMounted) {
return GreetingStickerCard(
accountId: _myId,
visible: greetingDue,
onSend: _sendSticker,
);
}
if (_isLoading) return const SizedBox.shrink();
if (empty && _isPersonDialog && _peerIsBot) {
return Center(
child: BotIntroCard(
botId: widget.chatId ^ _myId,
name: widget.name,
avatarUrl: widget.imageUrl,
),
);
}
if (!_commentsMode && _isGroupChat && _messages.every((m) => m.isControl)) {
return IgnorePointer(
child: Align(
alignment: const Alignment(0, -0.3),
child: ChatReadyCard(
name: _chatName,
avatarUrl: _chatImageUrl,
isChannel: _isChannel,
),
),
);
}
if (!empty) return const SizedBox.shrink();
return IgnorePointer(
child: Center(
child: Text(
AppLocalizations.of(context)!.chatEmptyTitle,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
Widget _buildMessagesList() => _messageListWidget ??= ScrollKeyboardDismiss(
onDismiss: _messageFocusNode.unfocus,
child: _ChatMessageList(this, key: _messageListKey),
);
EdgeInsets _messagesListPadding(BuildContext context) {
if (AppChatChrome.current.value == ChatChromeStyle.color) {
return const EdgeInsets.symmetric(vertical: 8);
}
final topInset = MediaQuery.paddingOf(context).top;
return EdgeInsets.only(top: topInset + 8, bottom: 8);
}
double _floatingDateTop(double pinnedHeight) {
if (AppChatChrome.current.value == ChatChromeStyle.color) {
final glossy = AppVisualStyle.current.value.glossyChrome;
return glossy ? 2 : 4;
}
if ((chat?.hasPinnedMessage == true || _showsCallBanner) &&
pinnedHeight > 0) {
return _pinnedBannerTop() + pinnedHeight + 2;
}
return _pinnedBannerTop() + 2;
}
Widget _buildLoadMoreIndicator() {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(child: SmallSpinner(size: 22, color: cs.onSurfaceVariant)),
);
}
Widget _buildMessagesListContent() {
if (_messages.isEmpty) return const SizedBox.shrink();
final items = _buildCombinedItems();
final visibleCount = _visibleMessageCount;
return Stack(
key: _listKey,
children: [
ValueListenableBuilder<double>(
valueListenable: AppCacheExtent.current,
builder: (context, userCacheExtent, _) =>
ValueListenableBuilder<double?>(
valueListenable: _jumpCacheExtent,
builder: (context, jumpExtent, _) {
final cacheExtent =
jumpExtent != null && jumpExtent < userCacheExtent
? jumpExtent
: userCacheExtent;
return AnchoredMessageList(
controller: _scrollController,
cacheExtent: cacheExtent,
padding: _messagesListPadding(context),
epoch: _listEpoch,
itemCount: items.length,
anchorIndex: _anchoredItemIndex(items),
loadingOlder: _isLoadingMore,
loadingIndicator: _buildLoadMoreIndicator(),
bottomSpacer: ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) =>
SizedBox(height: _composerUnderlap ? height : 0),
),
itemBuilder: (context, index) {
final item = items[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 < visibleCount - 1
? _messages[msgIndex + 1]
: null;
final bool isChannelPost =
!_commentsMode &&
(chat?.type ?? widget.chatType) == 'CHANNEL' &&
!message.isControl;
final bool isCommentedPost =
_commentsMode && message.id == widget.commentPostId;
final bubble = MessageBubble(
message: message,
isMe: isMe,
myId: _myId,
prevMessage: prevMessage,
nextMessage: nextMessage,
chatType: _commentsMode
? 'CHAT'
: (chat?.type ?? 'CHAT'),
chatId: widget.chatId,
photoActions: _photoActions,
overrideStatus: _effectiveStatus(message),
otherReadTime: _otherReadTime,
reactionsListenable: _reactionNotifierFor(message),
reactionAnimation: _reactionAnimation,
uploadProgress: _photoProgressFor(message),
onReplyTap: (id) =>
_jumpToMessage(id, fromId: message.id),
onAvatarTap: _openSenderProfile,
onForwardedSourceTap: _openForwardedSource,
onStickerTap: _openStickerPack,
onReactionTap: message.isControl
? null
: (emoji) => _reactToMessage(message, emoji),
peerName: widget.name,
peerAvatarUrl: widget.imageUrl,
senderNameOverride: isCommentedPost
? widget.name
: null,
senderAvatarOverride: isCommentedPost
? widget.imageUrl
: null,
textSelection: _textSelection,
textSelectionDrag: _textSelectionDrag,
onExitTextSelection: _exitTextSelection,
commentsLabel: isChannelPost
? _commentsLabelFor(message.id)
: null,
onCommentsTap: isChannelPost
? () => _openComments(message)
: null,
);
final canReport = !isMe && !message.isControl;
final reportTypeId = _complaintTypeId(
chat?.type ?? widget.chatType,
);
final pressable = SelectableMessageRow(
message: message,
reactionSettings: _reactionSettings,
composerHeight: _composerHeight,
isMe: isMe,
selectedIds: _selectedIds,
selectionAnim: _selectionAnim,
isSelectionActive: () => _selectionMode,
onToggleSelection: () => _toggleSelection(message),
onEnterSelection: () => _enterSelection(message),
onStartTextSelection: (pos) =>
_startTextSelection(message, pos),
onDragTextSelection: (pos) =>
_textSelectionDrag.value = pos,
onDelete: () => _confirmDeleteMessage(message.id, isMe),
onEdit: _canEditMessage(message)
? () => _startEditMessage(message)
: null,
onReply: message.isControl || !_canReply
? null
: () => _startReply(message),
onForward:
message.isControl ||
(chat?.forwardDisabled ?? false)
? null
: () => _forwardMessages([message]),
allowCopy: !(chat?.copyDisabled ?? false),
onMarkUnread: message.isControl
? null
: () => _markMessageUnread(message),
onCopyLink: _canLinkMessage(message)
? () => _copyMessageLink(message)
: null,
onPin: _canPinMessage(message)
? () => _togglePinMessage(message)
: null,
isPinned: () =>
chat?.pinnedMsgId == int.tryParse(message.id),
loadReadBy: _canShowReadBy(message)
? () => _loadReadBy(message)
: null,
onReaderTap: _openSenderProfile,
loadReportReasons: canReport
? () => _loadReportReasons(reportTypeId)
: null,
onReport: canReport
? (reasonId) => _reportMessage(
message,
reportTypeId,
reasonId,
)
: null,
onReact: message.isControl
? null
: (emoji) => _reactToMessage(message, emoji),
reactions: _reactionNotifierFor(message),
child: bubble,
);
final isChannel =
(chat?.type ?? widget.chatType) == 'CHANNEL';
final swipeable =
(message.isControl || isChannel || !_canReply)
? pressable
: SwipeToReply(
onReply: () => _startReply(message),
child: pressable,
);
final Widget child;
if (_deletingIds.contains(message.id)) {
child = _DeletingMessageAnimation(
key: ValueKey('del_${message.id}'),
onComplete: () => _finalizeDelete(message.id),
child: IgnorePointer(child: swipeable),
);
} else if (message.id == _lastSentId) {
child = _SentMessageAnimation(
key: ValueKey('anim_${message.id}'),
onComplete: () {
if (mounted) {
_lastSentId = null;
_bumpMessages();
}
},
child: swipeable,
);
} else {
child = swipeable;
}
final highlightable = ValueListenableBuilder<String?>(
valueListenable: _highlightMessageId,
builder: (context, hl, c) => AnimatedContainer(
duration: const Duration(milliseconds: 250),
color: hl == message.id
? Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12)
: Colors.transparent,
child: c,
),
child: child,
);
final builtItem = RepaintBoundary(
key: ValueKey('msg_${message.id}'),
child: KeyedSubtree(
key: _keyForMessage(message.id),
child: highlightable,
),
);
return message.id == _prank.bubbleId
? KeyedSubtree(
key: _prank.bubbleKey,
child: builtItem,
)
: builtItem;
},
);
},
),
),
ValueListenableBuilder<double>(
valueListenable: _pinnedBannerHeight,
builder: (context, pinnedHeight, child) => Positioned(
top: _floatingDateTop(pinnedHeight),
left: 0,
right: 0,
child: child!,
),
child: IgnorePointer(
child: ValueListenableBuilder<DateTime?>(
valueListenable: _floatingDate,
builder: (context, date, _) {
if (date == null) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _floatingDateCurved,
builder: (context, child) {
final t = _floatingDateCurved.value;
return Opacity(
opacity: t,
child: Transform.scale(
scale: 0.82 + 0.18 * t,
child: child,
),
);
},
child: _buildDateSeparatorWidget(
context,
date,
floating: true,
),
);
},
),
),
),
_buildScrollDownButton(),
],
);
}
Widget _buildScrollDownButton() => ScrollDownButton(
composerHeight: _composerHeight,
materialComposer: _materialComposer,
composerUnderlap: _composerUnderlap,
frosted: _effectiveChrome == ChatChromeStyle.transparent,
liquidChrome: _liquidChrome,
pillBackdrop: _pillBackdrop,
scrollDownCurved: _scrollDownCurved,
unreadCount: _unreadMessageCount,
onTap: _onScrollDownTap,
);
}
class _PinnedMessageBanner extends StatelessWidget {
final String? text;
final bool isPreview;
final VoidCallback onTap;
final VoidCallback? onUnpin;
final bool floating;
final bool frosted;
final bool liquid;
final BorderRadius? borderRadius;
final BackdropKey? backdropKey;
const _PinnedMessageBanner({
required this.text,
required this.isPreview,
required this.onTap,
this.onUnpin,
this.floating = false,
this.borderRadius,
this.frosted = false,
this.liquid = false,
this.backdropKey,
});
BorderRadius get _radius => borderRadius ?? BorderRadius.circular(16);
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final content = Material(
color: frosted
? AppFrost.glassTint(cs)
: floating
? cs.surfaceContainerHigh.withValues(alpha: 0.92)
: cs.surfaceContainerHigh,
borderRadius: floating ? _radius : null,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Container(
width: 3,
height: 34,
decoration: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
AppLocalizations.of(context)!.pinnedMessageTitle,
style: TextStyle(
color: cs.primary,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
_PinnedMessageText(
text: text,
isPreview: isPreview,
color: cs.onSurfaceVariant,
),
],
),
),
if (onUnpin != null) ...[
const SizedBox(width: 8),
IconButton(
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
iconSize: 20,
visualDensity: VisualDensity.compact,
onPressed: onUnpin,
),
],
],
),
),
),
);
final bottomBorder = Border(bottom: AppFrost.hairline(cs));
if (frosted) {
return GlassSurface(
liquid: liquid,
borderRadius: floating ? BorderRadius.circular(16) : BorderRadius.zero,
frostTint: Colors.transparent,
border: floating ? null : bottomBorder,
backdropKey: backdropKey,
child: content,
);
}
if (!floating) {
return DecoratedBox(
decoration: BoxDecoration(border: bottomBorder),
child: content,
);
}
return content;
}
}
class _PinnedMessageText extends StatefulWidget {
final String? text;
final bool isPreview;
final Color color;
const _PinnedMessageText({
required this.text,
required this.isPreview,
required this.color,
});
@override
State<_PinnedMessageText> createState() => _PinnedMessageTextState();
}
class _PinnedMessageTextState extends State<_PinnedMessageText> {
late String? _primaryText;
late bool _primaryIsPreview;
late String? _secondaryText;
late bool _secondaryIsPreview;
bool _showSecondary = false;
@override
void initState() {
super.initState();
_primaryText = widget.text;
_primaryIsPreview = widget.isPreview;
_secondaryText = widget.text;
_secondaryIsPreview = widget.isPreview;
}
@override
void didUpdateWidget(covariant _PinnedMessageText oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.text == oldWidget.text &&
widget.isPreview == oldWidget.isPreview) {
return;
}
if (_showSecondary) {
_primaryText = widget.text;
_primaryIsPreview = widget.isPreview;
} else {
_secondaryText = widget.text;
_secondaryIsPreview = widget.isPreview;
}
_showSecondary = !_showSecondary;
}
@override
Widget build(BuildContext context) {
return ClipRect(
child: AnimatedTextSwap(
showAlternate: _showSecondary,
alternate: _buildText(context, _secondaryText, _secondaryIsPreview),
child: _buildText(context, _primaryText, _primaryIsPreview),
),
);
}
Widget _buildText(BuildContext context, String? text, bool isPreview) {
final label = text == null || text.isEmpty
? AppLocalizations.of(context)!.msgActionsNoText
: text;
return Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: widget.color,
fontSize: 14,
fontStyle: isPreview ? FontStyle.italic : null,
),
);
}
}
class _DeletingMessageAnimation extends StatefulWidget {
final Widget child;
final VoidCallback onComplete;
const _DeletingMessageAnimation({
super.key,
required this.child,
required this.onComplete,
});
@override
State<_DeletingMessageAnimation> createState() =>
_DeletingMessageAnimationState();
}
class _DeletingMessageAnimationState extends State<_DeletingMessageAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _opacity;
late final Animation<double> _scale;
late final Animation<double> _collapse;
bool _fired = false;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 280),
);
_opacity = Tween<double>(
begin: 1,
end: 0,
).animate(CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6)));
_scale = Tween<double>(
begin: 1,
end: 0.82,
).animate(CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6)));
_collapse = Tween<double>(begin: 1, end: 0).animate(
CurvedAnimation(
parent: _ctrl,
curve: const Interval(0.35, 1.0, curve: Curves.easeInOut),
),
);
_ctrl.forward().whenComplete(() {
if (_fired) return;
_fired = true;
widget.onComplete();
});
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: _collapse,
alignment: AlignmentDirectional.centerStart,
child: FadeTransition(
opacity: _opacity,
child: ScaleTransition(
scale: _scale,
alignment: Alignment.center,
child: widget.child,
),
),
);
}
}
class _SentMessageAnimation extends StatefulWidget {
final Widget child;
final VoidCallback onComplete;
const _SentMessageAnimation({
super.key,
required this.child,
required this.onComplete,
});
@override
State<_SentMessageAnimation> createState() => _SentMessageAnimationState();
}
class _SentMessageAnimationState extends State<_SentMessageAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _opacity;
late final Animation<double> _slide;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
);
_opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
_slide = Tween<double>(
begin: 16,
end: 0,
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
_ctrl.forward().whenComplete(widget.onComplete);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) => Opacity(
opacity: _opacity.value,
child: Transform.translate(
offset: Offset(0, _slide.value),
child: child,
),
),
child: widget.child,
);
}
}
class _ChatMessageList extends StatefulWidget {
final _ChatScreenState host;
const _ChatMessageList(this.host, {super.key});
@override
State<_ChatMessageList> createState() => _ChatMessageListState();
}
class _ChatMessageListState extends State<_ChatMessageList> {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: widget.host._messagesRev,
builder: (context, _, _) => widget.host._buildMessagesListContent(),
);
}
}
class _EditMessageSheet extends StatefulWidget {
final String text;
final Iterable<FormatRange> formatRanges;
final Widget Function(
RichMessageController controller,
BuildContext context,
EditableTextState editableState,
)
contextMenuBuilder;
const _EditMessageSheet({
required this.text,
required this.formatRanges,
required this.contextMenuBuilder,
});
@override
State<_EditMessageSheet> createState() => _EditMessageSheetState();
}
class _EditMessageSheetState extends State<_EditMessageSheet> {
late final RichMessageController _controller;
@override
void initState() {
super.initState();
_controller = RichMessageController(text: widget.text)
..setFormatRanges(widget.formatRanges);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Изменить сообщение',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
fontFamily: displayFontOf(context),
),
),
const SizedBox(height: 16),
TextField(
controller: _controller,
autofocus: true,
minLines: 1,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
style: TextStyle(color: cs.onSurface),
contextMenuBuilder: (ctx, state) =>
widget.contextMenuBuilder(_controller, ctx, state),
decoration: InputDecoration(
hintText: 'Текст сообщения',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () =>
Navigator.of(context).pop(_controller.buildContent()),
child: const Text('Сохранить'),
),
],
),
);
}
}