diff --git a/lib/backend/modules/chat_parsing.dart b/lib/backend/modules/chat_parsing.dart index e92089f..06c0ee7 100644 --- a/lib/backend/modules/chat_parsing.dart +++ b/lib/backend/modules/chat_parsing.dart @@ -43,6 +43,7 @@ CachedChat? parseChatRow( final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing); final presence = _resolvePresence(type, otherId, presenceMap); final adminsOwner = _resolveAdmins(chat); + final pinned = _resolvePinnedMessage(chat['pinnedMessage']); return CachedChat( id: id, @@ -66,6 +67,10 @@ CachedChat? parseChatRow( options: titleIcon.options, owner: adminsOwner.owner, admins: adminsOwner.admins, + pinnedMsgId: pinned.id, + pinnedMsgText: pinned.text, + pinnedMsgTime: pinned.time, + pinnedMsgIsPreview: pinned.isPreview, ); } catch (e) { logger.e("Ошибка при парсинге чата: $e"); @@ -130,6 +135,24 @@ _resolveLastMessage(dynamic lastMsg) { ); } +({int? id, String? text, int? time, bool isPreview}) _resolvePinnedMessage( + dynamic pinned, +) { + if (pinned is! Map) { + return (id: null, text: null, time: null, isPreview: false); + } + final rawId = pinned['id']; + final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? ''); + if (id == null) return (id: null, text: null, time: null, isPreview: false); + final preview = pinnedMessagePreview(pinned); + return ( + id: id, + text: preview.text, + time: pinned['time'] as int?, + isPreview: preview.isPreview, + ); +} + ({int? favIndex, int dontDisturbUntil}) _resolveMuteAndFavorite( Map chatsConfig, int id, @@ -270,6 +293,10 @@ bool sameChatContent(CachedChat a, CachedChat b) { if (a.title != b.title) return false; if (a.iconUrl != b.iconUrl) return false; if (a.owner != b.owner) return false; + if (a.pinnedMsgId != b.pinnedMsgId) return false; + if (a.pinnedMsgText != b.pinnedMsgText) return false; + if (a.pinnedMsgTime != b.pinnedMsgTime) return false; + if (a.pinnedMsgIsPreview != b.pinnedMsgIsPreview) return false; if (a.dontDisturbUntil != b.dontDisturbUntil) return false; if (a.favIndex != b.favIndex) return false; if (a.lastMsgId != b.lastMsgId) return false; diff --git a/lib/backend/modules/chat_preview.dart b/lib/backend/modules/chat_preview.dart index 38b0497..a3e3d2b 100644 --- a/lib/backend/modules/chat_preview.dart +++ b/lib/backend/modules/chat_preview.dart @@ -1,15 +1,14 @@ import 'dart:convert'; String? attachPreviewLabel(dynamic attaches) { - if (attaches is! List || attaches.isEmpty) return null; - final first = attaches.first; - if (first is! Map) return null; + final first = _firstPreviewAttach(attaches); + if (first == null) return null; final type = (first['_type'] as String? ?? '').toUpperCase(); switch (type) { case 'PHOTO': return 'Фото'; case 'VIDEO': - return 'Видео'; + return _isVideoNote(first) ? 'Видео-сообщение' : 'Видео'; case 'AUDIO': return 'Голосовое сообщение'; case 'FILE': @@ -52,6 +51,23 @@ String? attachPreviewLabel(dynamic attaches) { } } +Map? _firstPreviewAttach(dynamic attaches) { + if (attaches is! List || attaches.isEmpty) return null; + for (final attach in attaches) { + if (attach is! Map) continue; + final type = (attach['_type'] as String? ?? '').toUpperCase(); + if (type == 'INLINE_KEYBOARD') continue; + return attach; + } + return null; +} + +bool _isVideoNote(Map attach) { + final raw = attach['videoType']; + if (raw is int) return raw == 1; + return raw?.toString() == '1'; +} + String? _controlPreviewLabel(Map c) { final title = c['title']?.toString(); if (title != null && title.isNotEmpty) return title; @@ -90,6 +106,60 @@ String? messagePreviewText(Map msg) { return _bodyPreviewText(msg); } +({String? text, bool isPreview}) pinnedMessagePreview(Map msg) { + final link = msg['link']; + if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') { + final original = link['message']; + if (original is Map) { + final inner = pinnedMessagePreview(original); + return inner.text != null && inner.text!.isNotEmpty + ? (text: '↪ ${inner.text}', isPreview: inner.isPreview) + : (text: '↪ пересланное сообщение', isPreview: true); + } + return (text: '↪ пересланное сообщение', isPreview: true); + } + return _pinnedBodyPreview(msg); +} + +({String? text, bool isPreview}) _pinnedBodyPreview(Map msg) { + final text = msg['text']?.toString(); + if (text != null && text.isNotEmpty) return (text: text, isPreview: false); + final label = _pinnedAttachPreviewLabel(msg['attaches']); + return (text: label, isPreview: label != null); +} + +String? _pinnedAttachPreviewLabel(dynamic attaches) { + final first = _firstPreviewAttach(attaches); + if (first == null) return null; + final type = (first['_type'] as String? ?? '').toUpperCase(); + switch (type) { + case 'PHOTO': + return 'фото'; + case 'VIDEO': + return _isVideoNote(first) ? 'кружок' : 'видео'; + case 'AUDIO': + return 'голосовое сообщение'; + case 'FILE': + return 'файл'; + case 'STICKER': + return 'стикер'; + case 'SHARE': + return 'ссылка'; + case 'POLL': + return 'голосование'; + case 'LOCATION': + return 'геопозиция'; + case 'CONTACT': + return 'контакт'; + case 'CALL': + return 'звонок'; + case 'CONTROL': + return _controlPreviewLabel(first)?.toLowerCase(); + default: + return 'вложение'; + } +} + String? _bodyPreviewText(Map msg) { final text = msg['text']?.toString(); if (text != null && text.isNotEmpty) return text; diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index f2fdc14..19c5642 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -59,6 +59,10 @@ class CachedChat { final Set options; final int? owner; final Set admins; + final int? pinnedMsgId; + final String? pinnedMsgText; + final int? pinnedMsgTime; + final bool pinnedMsgIsPreview; CachedChat({ required this.id, @@ -83,6 +87,10 @@ class CachedChat { this.options = const {}, this.owner, this.admins = const {}, + this.pinnedMsgId, + this.pinnedMsgText, + this.pinnedMsgTime, + this.pinnedMsgIsPreview = false, }) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n') ? lastMsgText.replaceAll('\n', ' ') : lastMsgText; @@ -110,6 +118,15 @@ class CachedChat { bool iAmAdmin(int myId) => owner == myId || admins.contains(myId); + bool get hasPinnedMessage => pinnedMsgId != null; + + bool get isGroupChat => type == 'CHAT' || type == 'GROUP'; + + bool canPinMessages(int myId) { + if (!isGroupChat) return false; + return iAmAdmin(myId) || options.contains('ALL_CAN_PIN_MESSAGE'); + } + bool get isMuted { if (dontDisturbUntil == ChatsModule.muteOff) return false; if (dontDisturbUntil < 0) return true; @@ -141,6 +158,10 @@ class CachedChat { options: _decodeOptions(row['options']), owner: row['owner'] as int?, admins: _decodeAdmins(row['admins']), + pinnedMsgId: row['pinned_msg_id'] as int?, + pinnedMsgText: row['pinned_msg_text'] as String?, + pinnedMsgTime: row['pinned_msg_time'] as int?, + pinnedMsgIsPreview: (row['pinned_msg_is_preview'] as int? ?? 0) == 1, ); static Set _decodeOptions(dynamic raw) { @@ -182,6 +203,10 @@ class CachedChat { 'options': options.isEmpty ? null : options.join(','), 'owner': owner, 'admins': admins.isEmpty ? null : admins.join(','), + 'pinned_msg_id': pinnedMsgId, + 'pinned_msg_text': pinnedMsgText, + 'pinned_msg_time': pinnedMsgTime, + 'pinned_msg_is_preview': pinnedMsgIsPreview ? 1 : 0, }; static const Object _keep = Object(); @@ -207,6 +232,10 @@ class CachedChat { Set? options, Object? owner = _keep, Set? admins, + Object? pinnedMsgId = _keep, + Object? pinnedMsgText = _keep, + Object? pinnedMsgTime = _keep, + bool? pinnedMsgIsPreview, }) { return CachedChat( id: id, @@ -243,6 +272,16 @@ class CachedChat { options: options ?? this.options, owner: identical(owner, _keep) ? this.owner : owner as int?, admins: admins ?? this.admins, + pinnedMsgId: identical(pinnedMsgId, _keep) + ? this.pinnedMsgId + : pinnedMsgId as int?, + pinnedMsgText: identical(pinnedMsgText, _keep) + ? this.pinnedMsgText + : pinnedMsgText as String?, + pinnedMsgTime: identical(pinnedMsgTime, _keep) + ? this.pinnedMsgTime + : pinnedMsgTime as int?, + pinnedMsgIsPreview: pinnedMsgIsPreview ?? this.pinnedMsgIsPreview, ); } } @@ -700,10 +739,45 @@ class ChatsModule { } if (unread != null) newRow['unread_count'] = unread; + final pinned = _extractPinnedMessage(msg); + if (pinned != null) { + newRow['pinned_msg_id'] = pinned.id; + newRow['pinned_msg_text'] = pinned.text; + newRow['pinned_msg_time'] = pinned.time; + newRow['pinned_msg_is_preview'] = pinned.isPreview ? 1 : 0; + } + await AppDatabase.saveChats([newRow]); _bump(); } + ({int? id, String? text, int? time, bool isPreview})? _extractPinnedMessage( + Map msg, + ) { + final attaches = msg['attaches']; + if (attaches is! List) return null; + for (final a in attaches.whereType()) { + if ((a['_type'] as String?) != 'CONTROL') continue; + final event = a['event']?.toString(); + if (event != 'pin' && event != 'unpin') continue; + final pinned = a['pinnedMessage']; + if (event == 'unpin' || pinned is! Map) { + return (id: null, text: null, time: null, isPreview: false); + } + final rawId = pinned['id']; + final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? ''); + if (id == null) return null; + final preview = pinnedMessagePreview(pinned.cast()); + return ( + id: id, + text: preview.text, + time: pinned['time'] as int?, + isPreview: preview.isPreview, + ); + } + return null; + } + Future _reconcileLastMessage( int accountId, int chatId, @@ -1269,6 +1343,39 @@ class ChatsModule { return true; } + Future setPinnedMessage( + Api api, { + required int chatId, + required int? messageId, + bool notify = true, + }) async { + try { + final packet = await api.sendRequest(Opcode.chatUpdate, { + 'chatId': chatId, + 'notifyPin': notify, + 'pinMessageId': messageId ?? 0, + }); + if (!packet.isOk) { + return messageFromErrorPayload(packet.payload); + } + final data = packet.payload; + final chat = data is Map ? data['chat'] : null; + if (chat is Map) { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await cacheServerChat(chat.cast(), accountId); + } + } + return null; + } on PacketError catch (e) { + logger.w('setPinnedMessage $chatId: ${e.message}'); + return e.message; + } catch (e) { + logger.w('setPinnedMessage $chatId: $e'); + return 'Не удалось изменить закрепление'; + } + } + Future togglePin( Api api, { required List chatIds, diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 52731c2..810b4ab 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -212,7 +212,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 17, + version: 19, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -296,6 +296,34 @@ class AppDatabase { await _createChatParticipantsIndex(db); await _backfillChatParticipants(db); } + if (oldVersion < 18) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_id', + 'INTEGER', + ); + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_text', + 'TEXT', + ); + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_time', + 'INTEGER', + ); + } + if (oldVersion < 19) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_is_preview', + 'INTEGER NOT NULL DEFAULT 0', + ); + } }, ); } @@ -444,6 +472,10 @@ class AppDatabase { owner INTEGER, admins TEXT, in_list INTEGER NOT NULL DEFAULT 1, + pinned_msg_id INTEGER, + pinned_msg_text TEXT, + pinned_msg_time INTEGER, + pinned_msg_is_preview INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (id, account_id) ) '''; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 31630cc..3730cd4 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:komet/backend/modules/chat_preview.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/file_uploader.dart'; import 'package:komet/backend/modules/upload_notification_service.dart'; @@ -17,10 +18,12 @@ import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/screens/contacts/contact_profile_screen.dart'; import 'package:komet/frontend/screens/chats/chat_list_screen.dart'; import 'package:komet/frontend/screens/chats/poll_create_screen.dart'; +import 'package:komet/frontend/widgets/animated_text_swap.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/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/complaints.dart'; @@ -568,10 +571,130 @@ class _ChatScreenState extends State Navigator.of(context).pop(); } + bool _canPinMessage(CachedMessage message) { + if (message.isControl) return false; + if (int.tryParse(message.id) == null) return false; + return chat?.canPinMessages(_myId) ?? false; + } + + Future _togglePinMessage(CachedMessage message) async { + final messageId = int.tryParse(message.id); + if (messageId == null) return; + final previousChat = chat; + final willUnpin = chat?.pinnedMsgId == messageId; + if (willUnpin) { + _applyPinnedMessageLocally(); + } else { + 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: willUnpin ? null : messageId, + notify: !willUnpin, + ); + if (!mounted) return; + if (error != null) { + if (previousChat != null) setState(() => chat = previousChat); + showCustomNotification(context, error); + return; + } + showCustomNotification( + context, + willUnpin ? 'Сообщение откреплено' : 'Сообщение закреплено', + ); + } + + Future _unpinCurrentMessage() async { + final previousChat = chat; + _applyPinnedMessageLocally(); + final error = await chats.setPinnedMessage( + api, + chatId: widget.chatId, + messageId: null, + notify: false, + ); + if (!mounted) return; + if (error != null) { + if (previousChat != null) setState(() => chat = previousChat); + showCustomNotification(context, error); + return; + } + showCustomNotification(context, 'Сообщение откреплено'); + } + + ({String? text, bool isPreview}) _pinnedPreviewFor(CachedMessage message) { + final payload = message.payload; + if (payload != null) return pinnedMessagePreview(payload); + return pinnedMessagePreview({ + 'text': message.text, + 'attaches': + message.attachments?.map((a) => a.toMap()).toList() ?? const [], + }); + } + + 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; + final time = chat?.pinnedMsgTime; + if (id == null) return; + unawaited(_openPinnedMessage(id.toString(), time ?? 0)); + } + + Future _openPinnedMessage(String messageId, int time) async { + if (!_messages.any((m) => m.id == messageId)) { + var guard = 0; + while (mounted && + guard < 60 && + _hasMoreHistory && + !_messages.any((m) => m.id == messageId) && + (_messages.isEmpty || _messages.first.time > time)) { + guard++; + final before = _messages.isEmpty ? 0 : _messages.first.time; + await _loadMoreHistory(); + if (!mounted) return; + final after = _messages.isEmpty ? 0 : _messages.first.time; + if (after == before) break; + } + if (!mounted) return; + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + } + if (_messages.any((m) => m.id == messageId)) { + _scrollToLoadedMessage(messageId); + } else { + showCustomNotification(context, 'Сообщение не загружено'); + } + } + bool _badgeRefreshing = false; bool _badgeRefreshQueued = false; void _onChatsBump() { + unawaited(_reloadChatMeta()); if (_badgeRefreshing) { _badgeRefreshQueued = true; return; @@ -579,6 +702,27 @@ class _ChatScreenState extends State unawaited(_runBadgeRefresh()); } + Future _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; + if (current != null && + 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)) { + return; + } + setState(() => chat = fresh); + } + Future _runBadgeRefresh() async { _badgeRefreshing = true; try { @@ -3108,10 +3252,26 @@ class _ChatScreenState extends State ); } + Widget? _buildPinnedBanner({required bool floating}) { + final pinned = chat; + if (pinned == null || !pinned.hasPinnedMessage) return null; + return _PinnedMessageBanner( + text: pinned.pinnedMsgText, + isPreview: pinned.pinnedMsgIsPreview, + floating: floating, + onTap: _jumpToPinnedMessage, + onUnpin: pinned.canPinMessages(_myId) + ? () => unawaited(_unpinCurrentMessage()) + : null, + ); + } + Widget _buildColorBody() { final cs = Theme.of(context).colorScheme; + final banner = _buildPinnedBanner(floating: false); return Column( children: [ + ?banner, Expanded( child: Stack( fit: StackFit.expand, @@ -3150,6 +3310,12 @@ class _ChatScreenState extends State Widget _buildUnderlapBody() { final cs = Theme.of(context).colorScheme; final vignette = AppChatChrome.current.value == ChatChromeStyle.none; + final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + final bannerTop = + MediaQuery.paddingOf(context).top + + (glossy ? _glossyHeaderHeight : kToolbarHeight) + + 8; + final banner = _buildPinnedBanner(floating: true); return Stack( fit: StackFit.expand, children: [ @@ -3185,6 +3351,8 @@ class _ChatScreenState extends State ), ), ], + if (banner != null) + Positioned(top: bannerTop, left: 8, right: 8, child: banner), ValueListenableBuilder( valueListenable: _composerHeight, builder: (context, height, _) => Positioned( @@ -3377,6 +3545,10 @@ class _ChatScreenState extends State onMarkUnread: message.isControl ? null : () => _markMessageUnread(message), + onPin: _canPinMessage(message) + ? () => _togglePinMessage(message) + : null, + isPinned: () => chat?.pinnedMsgId == int.tryParse(message.id), loadReportReasons: canReport ? () => _loadReportReasons(reportTypeId) : null, @@ -4474,6 +4646,175 @@ class _SwipeToReplyState extends State<_SwipeToReply> } } +class _PinnedMessageBanner extends StatelessWidget { + final String? text; + final bool isPreview; + final VoidCallback onTap; + final VoidCallback? onUnpin; + final bool floating; + + const _PinnedMessageBanner({ + required this.text, + required this.isPreview, + required this.onTap, + this.onUnpin, + this.floating = false, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final content = Material( + color: floating + ? cs.surfaceContainerHigh.withValues(alpha: 0.92) + : cs.surfaceContainerHigh, + borderRadius: floating ? BorderRadius.circular(16) : 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, + ), + ], + ], + ), + ), + ), + ); + + if (!floating) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.4), + width: 0.5, + ), + ), + ), + 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 _SelectableMessageRow extends StatefulWidget { final Widget child; final CachedMessage message; @@ -4488,6 +4829,8 @@ class _SelectableMessageRow extends StatefulWidget { final VoidCallback? onReply; final VoidCallback? onForward; final VoidCallback? onMarkUnread; + final VoidCallback? onPin; + final bool Function() isPinned; final Future> Function()? loadReportReasons; final Future Function(int reasonId)? onReport; @@ -4505,6 +4848,8 @@ class _SelectableMessageRow extends StatefulWidget { this.onReply, this.onForward, this.onMarkUnread, + this.onPin, + required this.isPinned, this.loadReportReasons, this.onReport, }); @@ -4519,6 +4864,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { final GlobalKey _boundaryKey = GlobalKey(); Offset? _lastTapDown; + bool _isPinnedNow() => widget.isPinned(); + void _openMenu() { final ctx = _boundaryKey.currentContext; if (ctx == null) return; @@ -4558,6 +4905,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { onReply: widget.onReply, onForward: widget.onForward, onMarkUnread: widget.onMarkUnread, + onPin: widget.onPin, + isPinned: _isPinnedNow(), onDispose: controller.dispose, ); } @@ -4589,6 +4938,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { onReply: widget.onReply, onForward: widget.onForward, onMarkUnread: widget.onMarkUnread, + onPin: widget.onPin, + isPinned: _isPinnedNow(), onDispose: controller.dispose, ); } diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 3e24a79..45af509 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -85,6 +85,8 @@ void showMessageActions({ VoidCallback? onReply, VoidCallback? onForward, VoidCallback? onMarkUnread, + VoidCallback? onPin, + bool isPinned = false, MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, }) { @@ -108,6 +110,8 @@ void showMessageActions({ onReply: onReply, onForward: onForward, onMarkUnread: onMarkUnread, + onPin: onPin, + isPinned: isPinned, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -135,6 +139,8 @@ class _MessageActionsLayer extends StatefulWidget { final VoidCallback? onReply; final VoidCallback? onForward; final VoidCallback? onMarkUnread; + final VoidCallback? onPin; + final bool isPinned; const _MessageActionsLayer({ required this.snapshot, @@ -154,6 +160,8 @@ class _MessageActionsLayer extends StatefulWidget { this.onReply, this.onForward, this.onMarkUnread, + this.onPin, + this.isPinned = false, }); @override @@ -392,6 +400,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _Action(Symbols.edit, l10n.msgActionsEdit, _edit), if (widget.onReply != null) _Action(Symbols.reply, l10n.msgActionsReply, _reply), + if (widget.onPin != null) + _Action( + widget.isPinned ? Symbols.keep_off : Symbols.push_pin, + widget.isPinned ? l10n.msgActionsUnpin : l10n.msgActionsPin, + _pin, + ), if (widget.onForward != null) _Action(Symbols.forward, l10n.msgActionsForward, _forward), if (widget.onMarkUnread != null) @@ -409,7 +423,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _showReportView, destructive: true, ), - _Action(Symbols.delete, l10n.msgActionsDelete, _delete, destructive: true), + _Action( + Symbols.delete, + l10n.msgActionsDelete, + _delete, + destructive: true, + ), ]; } @@ -503,7 +522,10 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (text != null && text.isNotEmpty) { await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; - showCustomNotification(context, AppLocalizations.of(context)!.msgActionsCopied); + showCustomNotification( + context, + AppLocalizations.of(context)!.msgActionsCopied, + ); } await _close(); } @@ -538,6 +560,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> onMarkUnread?.call(); } + Future _pin() async { + final onPin = widget.onPin; + await _close(); + onPin?.call(); + } + @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 470aaa0..16e0e91 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -847,6 +847,10 @@ class MessageBubble extends StatelessWidget { text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату'; break; + case 'pin': + text = + '${ContactCache.get(message.senderId) ?? 'Пользователь'} закрепил(а) сообщение'; + break; default: text = control.title; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7a7e9f6..a52c456 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -187,6 +187,9 @@ "msgActionsReply": "Reply", "msgActionsForward": "Forward", "msgActionsMarkUnread": "Mark as unread", + "msgActionsPin": "Pin", + "msgActionsUnpin": "Unpin", + "pinnedMessageTitle": "Pinned message", "msgActionsEditHistory": "Edit history", "msgActionsReport": "Report", "msgActionsDelete": "Delete", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d940139..7c1c966 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1118,6 +1118,24 @@ abstract class AppLocalizations { /// **'Mark as unread'** String get msgActionsMarkUnread; + /// No description provided for @msgActionsPin. + /// + /// In en, this message translates to: + /// **'Pin'** + String get msgActionsPin; + + /// No description provided for @msgActionsUnpin. + /// + /// In en, this message translates to: + /// **'Unpin'** + String get msgActionsUnpin; + + /// No description provided for @pinnedMessageTitle. + /// + /// In en, this message translates to: + /// **'Pinned message'** + String get pinnedMessageTitle; + /// No description provided for @msgActionsEditHistory. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 02b0e19..a9e1de7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -536,6 +536,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get msgActionsMarkUnread => 'Mark as unread'; + @override + String get msgActionsPin => 'Pin'; + + @override + String get msgActionsUnpin => 'Unpin'; + + @override + String get pinnedMessageTitle => 'Pinned message'; + @override String get msgActionsEditHistory => 'Edit history'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 031c554..47bc2b0 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -539,6 +539,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get msgActionsMarkUnread => 'Непрочитанное'; + @override + String get msgActionsPin => 'Закрепить'; + + @override + String get msgActionsUnpin => 'Открепить'; + + @override + String get pinnedMessageTitle => 'Закреплённое сообщение'; + @override String get msgActionsEditHistory => 'История изменений'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fd206c3..e7e04db 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -187,6 +187,9 @@ "msgActionsReply": "Ответить", "msgActionsForward": "Переслать", "msgActionsMarkUnread": "Непрочитанное", + "msgActionsPin": "Закрепить", + "msgActionsUnpin": "Открепить", + "pinnedMessageTitle": "Закреплённое сообщение", "msgActionsEditHistory": "История изменений", "msgActionsReport": "Пожаловаться", "msgActionsDelete": "Удалить",