diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 847377f..936e04a 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -307,18 +307,22 @@ class ChatSearchHit { class ChatMemberEntry { final int id; final String? name; + final String? fullName; final String? avatarUrl; final int? seenTime; final int presenceStatus; final bool blocked; + final bool isContact; const ChatMemberEntry({ required this.id, this.name, + this.fullName, this.avatarUrl, this.seenTime, required this.presenceStatus, this.blocked = false, + this.isContact = false, }); bool get isOnline => presenceStatus == 1; @@ -1865,10 +1869,12 @@ class ChatsModule { ChatMemberEntry( id: id, name: name, + fullName: info.fullName, avatarUrl: avatar, seenTime: seen, presenceStatus: status, blocked: info.isDeleted, + isContact: info.isSavedContact, ), ); } diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 0803533..edb7ba4 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -94,12 +94,21 @@ class AddContactResult { class ContactsModule { static final ValueNotifier revision = ValueNotifier(0); - static Future findByPhone(Api api, String phone) async { + static Future findByPhone( + Api api, + String phone, { + bool silent = false, + }) async { final normalized = _normalizePhone(phone); if (normalized == null) return null; - final packet = await api.sendRequest(Opcode.contactInfoByPhone, { - 'phone': normalized, - }); + final Packet packet; + try { + packet = await api.sendRequest(Opcode.contactInfoByPhone, { + 'phone': normalized, + }, silent: silent); + } on PacketError { + return null; + } if (packet.isError) return null; final contact = (packet.payload as Map?)?['contact']; if (contact is! Map) return null; diff --git a/lib/backend/modules/links.dart b/lib/backend/modules/links.dart index 4edfe9b..cb3ed56 100644 --- a/lib/backend/modules/links.dart +++ b/lib/backend/modules/links.dart @@ -31,7 +31,9 @@ abstract class LinkModule { static Future resolve(Api api, String url) async { final Packet response; try { - response = await api.sendRequest(Opcode.linkInfo, {'link': url}); + response = await api.sendRequest(Opcode.linkInfo, { + 'link': url, + }, silent: true); } on TimeoutException { return const ResolvedLinkError('Превышено время ожидания'); } on PacketError catch (e) { diff --git a/lib/core/utils/text_entities.dart b/lib/core/utils/text_entities.dart new file mode 100644 index 0000000..2d975ad --- /dev/null +++ b/lib/core/utils/text_entities.dart @@ -0,0 +1,179 @@ +enum TextEntityKind { mention, phone, card } + +class TextEntity { + final TextEntityKind kind; + final int start; + final int end; + final String value; + + const TextEntity({ + required this.kind, + required this.start, + required this.end, + required this.value, + }); + + int get length => end - start; +} + +typedef TextSpanRange = ({int start, int end}); + +final RegExp _cardPattern = RegExp( + r'(? _cardBrands = { + 'MIR': 'МИР', + 'VISA': 'Visa', + 'MASTERCARD': 'Mastercard', + 'MAESTRO': 'Maestro', + 'AMEX': 'American Express', + 'UNIONPAY': 'UnionPay', + 'JCB': 'JCB', + 'DINERS': 'Diners Club', + 'DISCOVER': 'Discover', +}; + +String? cardBrand(String digits) { + if (digits.length < 13) return null; + int prefix(int length) => int.parse(digits.substring(0, length)); + + final p1 = prefix(1); + final p2 = prefix(2); + final p3 = prefix(3); + final p4 = prefix(4); + final p6 = digits.length >= 6 ? prefix(6) : 0; + + if (p4 >= 2200 && p4 <= 2204) return 'MIR'; + if (p1 == 4) return 'VISA'; + if (p2 >= 51 && p2 <= 55) return 'MASTERCARD'; + if (p4 >= 2221 && p4 <= 2720) return 'MASTERCARD'; + if (p2 == 34 || p2 == 37) return 'AMEX'; + if (p2 == 62) return 'UNIONPAY'; + if (p4 >= 3528 && p4 <= 3589) return 'JCB'; + if (p3 >= 300 && p3 <= 305) return 'DINERS'; + if (p2 == 36 || p2 == 38 || p2 == 39) return 'DINERS'; + if (p4 == 6011 || p2 == 65) return 'DISCOVER'; + if (p3 >= 644 && p3 <= 649) return 'DISCOVER'; + if (p6 >= 622126 && p6 <= 622925) return 'DISCOVER'; + if (p4 == 5018 || p4 == 5020 || p4 == 5038 || p4 == 6304) return 'MAESTRO'; + if (p4 == 6759 || (p4 >= 6761 && p4 <= 6763)) return 'MAESTRO'; + return null; +} + +String? cardBrandTitle(String digits) { + final brand = cardBrand(digits); + return brand == null ? null : _cardBrands[brand]; +} + +String cardMask(String digits) { + final brand = cardBrand(digits) ?? 'CARD'; + final tail = digits.length >= 4 + ? digits.substring(digits.length - 4) + : digits; + return '$brand*$tail'; +} + +String formatCardNumber(String digits) { + final buffer = StringBuffer(); + for (var i = 0; i < digits.length; i++) { + if (i > 0 && i % 4 == 0) buffer.write(' '); + buffer.write(digits[i]); + } + return buffer.toString(); +} + +bool isLuhnValid(String digits) { + if (digits.length < 12) return false; + var sum = 0; + var double = false; + for (var i = digits.length - 1; i >= 0; i--) { + var value = digits.codeUnitAt(i) - 0x30; + if (value < 0 || value > 9) return false; + if (double) { + value *= 2; + if (value > 9) value -= 9; + } + sum += value; + double = !double; + } + return sum % 10 == 0; +} + +String _digitsOf(String raw) { + final buffer = StringBuffer(); + for (var i = 0; i < raw.length; i++) { + final code = raw.codeUnitAt(i); + if (code >= 0x30 && code <= 0x39) buffer.writeCharCode(code); + } + return buffer.toString(); +} + +bool _mayContainEntities(String text) { + for (var i = 0; i < text.length; i++) { + final code = text.codeUnitAt(i); + if (code == 0x40) return true; + if (code >= 0x30 && code <= 0x39) return true; + } + return false; +} + +List detectTextEntities( + String text, { + Iterable skip = const [], +}) { + if (text.isEmpty || !_mayContainEntities(text)) return const []; + + final taken = [...skip]; + bool free(int start, int end) => + !taken.any((r) => start < r.end && end > r.start); + + final found = []; + + void collect( + RegExp pattern, + TextEntityKind kind, + String? Function(RegExpMatch match) valueOf, + ) { + for (final match in pattern.allMatches(text)) { + if (!free(match.start, match.end)) continue; + final value = valueOf(match); + if (value == null) continue; + taken.add((start: match.start, end: match.end)); + found.add( + TextEntity( + kind: kind, + start: match.start, + end: match.end, + value: value, + ), + ); + } + } + + collect(_cardPattern, TextEntityKind.card, (match) { + final digits = _digitsOf(match.group(0)!); + if (digits.length < 13 || digits.length > 19) return null; + if (cardBrand(digits) == null) return null; + if (!isLuhnValid(digits)) return null; + return digits; + }); + + collect(_phonePattern, TextEntityKind.phone, (match) { + final digits = _digitsOf(match.group(0)!); + if (digits.length < 10 || digits.length > 15) return null; + return '+$digits'; + }); + + collect(_mentionPattern, TextEntityKind.mention, (match) => match.group(1)); + + found.sort((a, b) => a.start.compareTo(b.start)); + return found; +} + +bool hasTextEntities(String text, {Iterable skip = const []}) => + detectTextEntities(text, skip: skip).isNotEmpty; diff --git a/lib/core/utils/text_format.dart b/lib/core/utils/text_format.dart index 4100ad9..15b870f 100644 --- a/lib/core/utils/text_format.dart +++ b/lib/core/utils/text_format.dart @@ -9,6 +9,7 @@ enum TextFormat { quote, link, animoji, + userMention, } const Map _formatToServer = { @@ -20,6 +21,7 @@ const Map _formatToServer = { TextFormat.quote: 'QUOTE', TextFormat.link: 'LINK', TextFormat.animoji: 'ANIMOJI', + TextFormat.userMention: 'USER_MENTION', }; final Map _serverToFormat = { @@ -35,12 +37,16 @@ class FormatRange { final TextFormat format; final int start; final int length; + final int? entityId; + final String? entityName; final Map? attributes; const FormatRange({ required this.format, required this.start, required this.length, + this.entityId, + this.entityName, this.attributes, }); @@ -60,6 +66,8 @@ class FormatRange { 'type': textFormatToServer(format), 'from': start, 'length': length, + if (entityId != null) 'entityId': entityId, + if (entityName != null) 'entityName': entityName, if (attributes != null) 'attributes': attributes, }; } @@ -78,11 +86,17 @@ List parseFormatElements(dynamic raw) { final attributes = attrsRaw is Map ? Map.from(attrsRaw) : null; + final entityId = item['entityId']; + final entityName = item['entityName']; result.add( FormatRange( format: format, start: from, length: length, + entityId: entityId is int ? entityId : null, + entityName: entityName is String && entityName.isNotEmpty + ? entityName + : null, attributes: attributes, ), ); @@ -134,6 +148,8 @@ class FormatSegment { final Set formats; final String? url; final String? animojiUrl; + final int? mentionId; + final String? mentionName; const FormatSegment({ required this.start, @@ -141,6 +157,8 @@ class FormatSegment { required this.formats, this.url, this.animojiUrl, + this.mentionId, + this.mentionName, }); } @@ -157,6 +175,8 @@ List segmentizeFormats(String text, List ranges) { format: range.format, start: start, length: end - start, + entityId: range.entityId, + entityName: range.entityName, attributes: range.attributes, ), ); @@ -180,11 +200,17 @@ List segmentizeFormats(String text, List ranges) { final formats = {}; String? url; String? animojiUrl; + int? mentionId; + String? mentionName; for (final range in clamped) { if (range.start <= start && range.end >= end) { formats.add(range.format); if (range.format == TextFormat.link) url ??= range.url; if (range.format == TextFormat.animoji) animojiUrl ??= range.animojiUrl; + if (range.format == TextFormat.userMention) { + mentionId ??= range.entityId; + mentionName ??= range.entityName; + } } } segments.add( @@ -194,6 +220,8 @@ List segmentizeFormats(String text, List ranges) { formats: formats, url: url, animojiUrl: animojiUrl, + mentionId: mentionId, + mentionName: mentionName, ), ); } @@ -204,6 +232,7 @@ TextStyle applyTextFormats( TextStyle base, Set formats, { Color? quoteColor, + Color? mentionColor, }) { if (formats.isEmpty) return base; @@ -219,11 +248,15 @@ TextStyle applyTextFormats( final isItalic = formats.contains(TextFormat.emphasized) || formats.contains(TextFormat.quote); + final isMention = formats.contains(TextFormat.userMention); + return base.copyWith( fontWeight: formats.contains(TextFormat.strong) ? FontWeight.w700 : null, fontStyle: isItalic ? FontStyle.italic : null, fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : null, - color: formats.contains(TextFormat.quote) ? quoteColor : null, + color: isMention + ? mentionColor + : (formats.contains(TextFormat.quote) ? quoteColor : null), decoration: decorations.isEmpty ? null : TextDecoration.combine(decorations), diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 5068710..0ce16a7 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -10,6 +10,7 @@ import '../../../core/calls/call_controller.dart'; import '../../../backend/modules/calls.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/chat_menu_overlay.dart'; import '../../widgets/small_spinner.dart'; @@ -22,7 +23,7 @@ class CallsTab extends StatefulWidget { State createState() => _CallsTabState(); } -class _CallsTabState extends State { +class _CallsTabState extends State with ReloadOnReconnect { List _calls = []; final Set _removing = {}; bool _isLoading = true; @@ -51,6 +52,11 @@ class _CallsTabState extends State { super.dispose(); } + @override + void reloadAfterReconnect() { + if (accountModule.isLoggedIn) _loadHistory(); + } + Future _loadHistory() async { final p = await AppDatabase.loadActiveProfile(); if (p == null) { diff --git a/lib/frontend/screens/chats/chat/mention_panel_controller.dart b/lib/frontend/screens/chats/chat/mention_panel_controller.dart new file mode 100644 index 0000000..2ec1f0b --- /dev/null +++ b/lib/frontend/screens/chats/chat/mention_panel_controller.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../../backend/modules/chats.dart' show chats; +import '../../../../main.dart' show api; + +class MentionCandidate { + final int id; + final String name; + final String? avatarUrl; + final bool isContact; + + const MentionCandidate({ + required this.id, + required this.name, + this.avatarUrl, + this.isContact = false, + }); + + @override + bool operator ==(Object other) => + other is MentionCandidate && + other.id == id && + other.name == name && + other.isContact == isContact; + + @override + int get hashCode => Object.hash(id, name, isContact); +} + +class MentionQuery { + final int start; + final int end; + final String text; + + const MentionQuery({ + required this.start, + required this.end, + required this.text, + }); +} + +MentionQuery? mentionQueryAt(String text, int cursor) { + if (cursor <= 0 || cursor > text.length) return null; + const maxQueryLength = 32; + + var index = cursor - 1; + while (index >= 0) { + final code = text.codeUnitAt(index); + if (code == 0x40) break; + if (code == 0x20 || code == 0x0A || code == 0x09) return null; + if (cursor - index > maxQueryLength) return null; + index--; + } + if (index < 0) return null; + if (index > 0) { + final before = text.codeUnitAt(index - 1); + if (before != 0x20 && before != 0x0A && before != 0x09) return null; + } + + return MentionQuery( + start: index, + end: cursor, + text: text.substring(index + 1, cursor), + ); +} + +class MentionPanelController { + MentionPanelController({ + required TickerProvider vsync, + required this.chatId, + required this.enabled, + required this.selfId, + required this.valueOf, + required this.onSelected, + }) { + anim = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 200), + ); + } + + static const int _pageSize = 50; + static const int _desiredMatches = 30; + static const int _autoFetchLimit = 300; + + final int chatId; + final bool Function() enabled; + final int Function() selfId; + final TextEditingValue Function() valueOf; + final void Function(MentionCandidate candidate, MentionQuery query) + onSelected; + + late final AnimationController anim; + final ValueNotifier> matches = ValueNotifier(const []); + final ValueNotifier loadingMore = ValueNotifier(false); + + final List _members = []; + final Set _seen = {}; + int _marker = 0; + bool _end = false; + bool _fetching = false; + bool _visible = false; + MentionQuery? _query; + + bool get hasMore => !_end; + + void update() { + final query = enabled() ? _queryAt(valueOf()) : null; + _query = query; + + if (query == null) { + _setVisible(false); + return; + } + + if (_members.isEmpty && !_end) unawaited(_fetchPage()); + + final found = _match(query.text); + if (!listEquals(matches.value, found)) matches.value = found; + if (found.length < _desiredMatches && _members.length < _autoFetchLimit) { + unawaited(_fetchPage()); + } + + _setVisible(found.isNotEmpty || (_members.isEmpty && !_end)); + } + + void select(MentionCandidate candidate) { + final query = _query; + if (query == null) return; + onSelected(candidate, query); + } + + Future loadMore() => _fetchPage(); + + MentionQuery? _queryAt(TextEditingValue value) { + final selection = value.selection; + if (!selection.isValid || !selection.isCollapsed) return null; + return mentionQueryAt(value.text, selection.baseOffset); + } + + List _match(String raw) { + final me = selfId(); + final query = raw.toLowerCase().trim(); + final found = _members + .where((c) => c.id != me) + .where((c) => query.isEmpty || _matchesQuery(c.name, query)); + return [ + ...found.where((c) => c.isContact), + ...found.where((c) => !c.isContact), + ]; + } + + bool _matchesQuery(String name, String query) { + final lower = name.toLowerCase(); + if (lower.startsWith(query)) return true; + for (final word in lower.split(' ')) { + if (word.startsWith(query)) return true; + } + return lower.contains(query); + } + + Future _fetchPage() async { + if (_fetching || _end) return; + _fetching = true; + loadingMore.value = true; + try { + final page = await chats.getChatMembers( + api, + chatId, + marker: _marker, + count: _pageSize, + ); + if (page == null) { + _end = true; + return; + } + + var added = 0; + for (final member in page.members) { + final name = member.fullName ?? member.name; + if (name == null || name.isEmpty) continue; + if (member.blocked) continue; + if (!_seen.add(member.id)) continue; + _members.add( + MentionCandidate( + id: member.id, + name: name, + avatarUrl: member.avatarUrl, + isContact: member.isContact, + ), + ); + added++; + } + + if (added == 0 || page.members.isEmpty || page.marker == _marker) { + _end = true; + } + _marker = page.marker; + + if (added > 0 && _query != null) { + final found = _match(_query!.text); + if (!listEquals(matches.value, found)) matches.value = found; + _setVisible(found.isNotEmpty); + } + } finally { + _fetching = false; + loadingMore.value = false; + } + } + + void _setVisible(bool show) { + if (show == _visible) return; + _visible = show; + if (show) { + anim.forward(); + } else { + anim.reverse(); + } + } + + void dispose() { + anim.dispose(); + matches.dispose(); + loadingMore.dispose(); + } +} diff --git a/lib/frontend/screens/chats/chat/view/mention_panel_view.dart b/lib/frontend/screens/chats/chat/view/mention_panel_view.dart new file mode 100644 index 0000000..cf33ed1 --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/mention_panel_view.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:komet/frontend/screens/chats/chat/mention_panel_controller.dart'; +import 'package:komet/frontend/widgets/mention_suggestions_panel.dart'; + +class MentionPanelView extends StatelessWidget { + const MentionPanelView({super.key, required this.mentionPanel}); + + final MentionPanelController mentionPanel; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: mentionPanel.anim, + child: ValueListenableBuilder>( + valueListenable: mentionPanel.matches, + builder: (context, matches, _) => ValueListenableBuilder( + valueListenable: mentionPanel.loadingMore, + builder: (context, loading, _) => MentionSuggestionsPanel( + candidates: matches, + loadingMore: loading && mentionPanel.hasMore, + onSelected: mentionPanel.select, + onLoadMore: mentionPanel.loadMore, + ), + ), + ), + builder: (context, child) { + final t = mentionPanel.anim.value; + if (t == 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: IgnorePointer( + ignoring: t < 1, + child: Opacity(opacity: t, child: child), + ), + ); + }, + ); + } +} diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 6c73c87..44b075e 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -17,6 +17,8 @@ import '../../widgets/animated_text_swap.dart'; import '../../widgets/avatar_history_screen.dart'; import '../../widgets/chat_info/shared_content_tabs.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/formatted_message_text.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/swipe_route.dart'; @@ -83,7 +85,8 @@ class ChatInfoScreen extends StatefulWidget { State createState() => _ChatInfoScreenState(); } -class _ChatInfoScreenState extends State { +class _ChatInfoScreenState extends State + with ReloadOnReconnect { final _tabScrollController = ScrollController(); final _bodyScrollController = ScrollController(); @@ -175,6 +178,9 @@ class _ChatInfoScreenState extends State { } } + @override + void reloadAfterReconnect() => _load(); + Future _load() async { final profile = await AppDatabase.loadActiveProfile(); _myId = profile?.id ?? 0; @@ -789,7 +795,12 @@ class _ChatInfoScreenState extends State { : int.tryParse(phone?.toString() ?? ''); if (phoneInt != null && phoneInt > 0) { items.add( - _simpleInfoCard(cs, l10n.loginPhoneNumber, formatPhone(phoneInt)!), + _simpleInfoCard( + cs, + l10n.loginPhoneNumber, + formatPhone(phoneInt)!, + entities: true, + ), ); } final bio = @@ -797,7 +808,7 @@ class _ChatInfoScreenState extends State { (_contactData?.raw['about'] as String?); if (bio != null && bio.isNotEmpty) { if (items.isNotEmpty) items.add(const SizedBox(height: 8)); - items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio)); + items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio, entities: true)); } } } else if (widget.chatType == 'CHANNEL') { @@ -824,6 +835,7 @@ class _ChatInfoScreenState extends State { String label, String value, { bool isLink = false, + bool entities = false, }) { return GlossyPill( color: cs.surfaceContainerHigh, @@ -840,14 +852,26 @@ class _ChatInfoScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), - Text( - value, - style: TextStyle( - color: isLink ? cs.primary : cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, + if (entities) + FormattedMessageText( + text: value, + ranges: const [], + entityMode: TextEntityMode.copy, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ) + else + Text( + value, + style: TextStyle( + color: isLink ? cs.primary : cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), ), - ), ], ), ), @@ -905,8 +929,10 @@ class _ChatInfoScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), - Text( - desc, + FormattedMessageText( + text: desc, + ranges: const [], + entityMode: TextEntityMode.copy, style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4), maxLines: (_descExpanded || !isLong) ? null : collapsedLines, overflow: (_descExpanded || !isLong) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 9f55e54..f18fd2d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -65,6 +65,8 @@ 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/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'; @@ -93,6 +95,8 @@ import '../../widgets/sticker_pack_sheet.dart'; import '../../widgets/small_spinner.dart'; import '../../widgets/swipe_to_pop.dart'; import '../../widgets/swipe_route.dart'; +import '../../widgets/directional_drag_recognizer.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'; @@ -236,7 +240,7 @@ class ChatScreen extends StatefulWidget { } class _ChatScreenState extends State - with TickerProviderStateMixin, WidgetsBindingObserver { + with TickerProviderStateMixin, WidgetsBindingObserver, ReloadOnReconnect { final RichMessageController _messageController = RichMessageController(); final FocusNode _messageFocusNode = FocusNode(); double _keyboardReserve = 0; @@ -454,6 +458,7 @@ class _ChatScreenState extends State int _tempIdCounter = 0; late final AnimationController _attachAnim; late final CommandPanelController _commandPanel; + late final MentionPanelController _mentionPanel; String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; @@ -602,6 +607,14 @@ class _ChatScreenState extends State 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), @@ -683,6 +696,13 @@ class _ChatScreenState extends State WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); } + @override + void reloadAfterReconnect() { + if (!_historyKickedOff) return; + unawaited(_loadHistory()); + unawaited(_loadParticipantsCount()); + } + Future _loadParticipantsCount() async { if (_commentsMode) return; if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return; @@ -1857,6 +1877,7 @@ class _ChatScreenState extends State _uploadStatus.dispose(); _attachAnim.dispose(); _commandPanel.dispose(); + _mentionPanel.dispose(); _selectionAnim.dispose(); _searchAnim.dispose(); _searchFocusNode.dispose(); @@ -1884,6 +1905,21 @@ class _ChatScreenState extends State _hasText.value = newHasText; } _commandPanel.update(); + _mentionPanel.update(); + } + + bool _mentionsAvailable() => + !_commentsMode && (chat?.type ?? widget.chatType) == 'CHAT'; + + void _onMentionSelected(MentionCandidate candidate, MentionQuery query) { + _messageController.insertMention( + userId: candidate.id, + name: candidate.name, + start: query.start, + end: query.end, + ); + _mentionPanel.update(); + _messageFocusNode.requestFocus(); } void _onCommandSelected(SlashCommand c) { @@ -3371,6 +3407,8 @@ class _ChatScreenState extends State return 'Ссылка'; case TextFormat.animoji: return 'Animoji'; + case TextFormat.userMention: + return 'Упоминание'; } } @@ -4794,7 +4832,13 @@ class _ChatScreenState extends State left: 0, right: 0, bottom: frosted ? height : 0, - child: CommandPanelView(commandPanel: _commandPanel), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MentionPanelView(mentionPanel: _mentionPanel), + CommandPanelView(commandPanel: _commandPanel), + ], + ), ), ), if (frosted) @@ -4884,7 +4928,13 @@ class _ChatScreenState extends State left: 0, right: 0, bottom: height, - child: CommandPanelView(commandPanel: _commandPanel), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MentionPanelView(mentionPanel: _mentionPanel), + CommandPanelView(commandPanel: _commandPanel), + ], + ), ), ), Positioned( @@ -6346,6 +6396,12 @@ class _SwipeToReplyState extends State<_SwipeToReply> void _onDragEnd(DragEndDetails d) { if (_triggered) widget.onReply(); + _settle(); + } + + void _onDragCancel() => _settle(); + + void _settle() { _triggered = false; _springFrom = _dragX; _springBack.forward(from: 0); @@ -6355,10 +6411,20 @@ class _SwipeToReplyState extends State<_SwipeToReply> Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0); - return GestureDetector( + return RawGestureDetector( behavior: HitTestBehavior.opaque, - onHorizontalDragUpdate: _onDragUpdate, - onHorizontalDragEnd: _onDragEnd, + gestures: { + LeftwardDragRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LeftwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _onDragCancel; + }, + ), + }, child: Stack( alignment: Alignment.centerRight, children: [ diff --git a/lib/frontend/screens/chats/scheduled_messages_screen.dart b/lib/frontend/screens/chats/scheduled_messages_screen.dart index d10c6e1..cbc9767 100644 --- a/lib/frontend/screens/chats/scheduled_messages_screen.dart +++ b/lib/frontend/screens/chats/scheduled_messages_screen.dart @@ -18,6 +18,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/schedule_time_picker.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/small_spinner.dart'; +import '../../widgets/reload_on_reconnect.dart'; class ScheduledMessagesScreen extends StatefulWidget { final int chatId; @@ -36,7 +37,8 @@ class ScheduledMessagesScreen extends StatefulWidget { _ScheduledMessagesScreenState(); } -class _ScheduledMessagesScreenState extends State { +class _ScheduledMessagesScreenState extends State + with ReloadOnReconnect { final List _messages = []; StreamSubscription? _pushSub; bool _loading = true; @@ -61,6 +63,9 @@ class _ScheduledMessagesScreenState extends State { super.dispose(); } + @override + void reloadAfterReconnect() => _load(); + Future _load() async { final list = await messagesModule.fetchDelayedMessages( widget.accountId, diff --git a/lib/frontend/screens/digital_id/digital_id_screen.dart b/lib/frontend/screens/digital_id/digital_id_screen.dart index 8bb1fa6..fdcfbc3 100644 --- a/lib/frontend/screens/digital_id/digital_id_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_screen.dart @@ -10,6 +10,7 @@ import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show digitalIdModule, webAppModule; import '../../../models/digital_id.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/error_view.dart'; import '../../widgets/small_spinner.dart'; @@ -41,7 +42,8 @@ class DigitalIdScreen extends StatefulWidget { State createState() => _DigitalIdScreenState(); } -class _DigitalIdScreenState extends State { +class _DigitalIdScreenState extends State + with ReloadOnReconnect { bool _loading = true; bool _busy = false; String? _error; @@ -56,6 +58,9 @@ class _DigitalIdScreenState extends State { _load(); } + @override + void reloadAfterReconnect() => _load(); + Future _load() async { setState(() { _loading = true; diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index 7a40dee..1364b79 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -15,6 +15,7 @@ import '../../../core/utils/format.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; @@ -30,7 +31,7 @@ class CloudStorageScreen extends StatefulWidget { } class _CloudStorageScreenState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, ReloadOnReconnect { static const _translateFactor = 0.7; static const _horizontalPadding = 32.0; static const _hintSidePadding = 35.0; @@ -185,6 +186,17 @@ class _CloudStorageScreenState extends State } } + @override + void reloadAfterReconnect() { + final accountId = _accountId; + final groupId = _envGroupId; + if (accountId == null || groupId == null) { + _checkEnv(); + return; + } + unawaited(_loadFiles(accountId, groupId)); + } + Future _loadFiles(int accountId, int chatId) async { final files = await CloudStorageModule.fetchFiles( messagesModule, diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 96ce1fa..9104980 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -12,6 +12,7 @@ import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show SessionInfo; import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/prompt_dialog.dart'; import '../../widgets/small_spinner.dart'; @@ -26,7 +27,7 @@ class DevicesScreen extends StatefulWidget { } class _DevicesScreenState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, ReloadOnReconnect { bool _isLoading = true; List _sessions = []; final Map> _ipDetails = {}; @@ -50,6 +51,9 @@ class _DevicesScreenState extends State super.dispose(); } + @override + void reloadAfterReconnect() => _loadSessions(); + Future _loadSessions() async { try { final sessions = await accountModule.getSessions(); diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index ec0144a..07d6bd3 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -5,6 +5,7 @@ import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show accountModule, isOnemeFlavor; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/section_header.dart'; import '../../widgets/settings_card.dart'; @@ -17,7 +18,8 @@ class NotificationsScreen extends StatefulWidget { State createState() => _NotificationsScreenState(); } -class _NotificationsScreenState extends State { +class _NotificationsScreenState extends State + with ReloadOnReconnect { bool _loading = true; bool _saving = false; @@ -34,6 +36,9 @@ class _NotificationsScreenState extends State { _load(); } + @override + void reloadAfterReconnect() => _load(); + Future _load() async { final config = await accountModule.getPrivacyConfig(); if (!mounted) return; diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 26aab4e..fab53e7 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -10,6 +10,7 @@ import '../../../l10n/app_localizations.dart'; import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/small_spinner.dart'; @@ -23,7 +24,7 @@ class SecurityScreen extends StatefulWidget { } class _SecurityScreenState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, ReloadOnReconnect { bool _isLoading = true; bool _isSaving = false; bool _is2faEnabled = false; @@ -47,6 +48,9 @@ class _SecurityScreenState extends State super.dispose(); } + @override + void reloadAfterReconnect() => _loadData(); + Future _loadData() async { try { final results = await Future.wait([ diff --git a/lib/frontend/widgets/attachment/bubbles/control_bubble.dart b/lib/frontend/widgets/attachment/bubbles/control_bubble.dart new file mode 100644 index 0000000..b68af20 --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/control_bubble.dart @@ -0,0 +1,163 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../../../backend/modules/messages.dart'; +import '../../../../models/attachment.dart'; + +class _ControlSegment { + final String text; + final int? userId; + + const _ControlSegment(this.text, [this.userId]); +} + +class _ControlText { + final List<_ControlSegment> segments; + final int? tapUserId; + + const _ControlText(this.segments, this.tapUserId); +} + +class ControlBubble extends StatefulWidget { + final CachedMessage message; + final ColorScheme cs; + final void Function(int userId)? onUserTap; + + const ControlBubble({ + super.key, + required this.message, + required this.cs, + this.onUserTap, + }); + + @override + State createState() => _ControlBubbleState(); +} + +class _ControlBubbleState extends State { + final Map _recognizers = {}; + + @override + void dispose() { + for (final recognizer in _recognizers.values) { + recognizer.dispose(); + } + super.dispose(); + } + + TapGestureRecognizer _recognizerFor(int userId) => _recognizers.putIfAbsent( + userId, + () => TapGestureRecognizer()..onTap = () => widget.onUserTap?.call(userId), + ); + + String _nameOf(int userId) => ContactCache.get(userId) ?? 'Пользователь'; + + int? _mentionedUser(ControlAttachment control) { + final direct = control.userId; + if (direct != null && direct != 0) return direct; + final ids = control.userIds; + if (ids != null && ids.length == 1) return ids.first; + return null; + } + + _ControlText _resolveText(ControlAttachment control) { + final senderId = widget.message.senderId; + final sender = _ControlSegment(_nameOf(senderId), senderId); + + switch (control.event) { + case 'new': + return _ControlText([ + sender, + const _ControlSegment(' создал(а) чат'), + ], senderId); + case 'add': + final ids = control.userIds ?? const []; + final segments = <_ControlSegment>[ + sender, + const _ControlSegment(' добавил(а) '), + ]; + for (var i = 0; i < ids.length; i++) { + if (i > 0) segments.add(const _ControlSegment(', ')); + segments.add(_ControlSegment(_nameOf(ids[i]), ids[i])); + } + return _ControlText(segments, ids.length == 1 ? ids.first : null); + case 'leave': + return _ControlText([ + sender, + const _ControlSegment(' покинул(а) чат'), + ], senderId); + case 'joinByLink': + return _ControlText([ + sender, + const _ControlSegment(' присоединился(-ась) к чату'), + ], senderId); + case 'pin': + return _ControlText([ + sender, + const _ControlSegment(' закрепил(а) сообщение'), + ], senderId); + default: + return _ControlText([ + _ControlSegment(control.title ?? ''), + ], _mentionedUser(control) ?? senderId); + } + } + + @override + Widget build(BuildContext context) { + final attachments = widget.message.attachments; + if (attachments == null || attachments.isEmpty) { + return const SizedBox.shrink(); + } + + final control = attachments.first; + if (control is! ControlAttachment) return const SizedBox.shrink(); + + final resolved = _resolveText(control); + if (resolved.segments.every((s) => s.text.isEmpty)) { + return const SizedBox.shrink(); + } + + final cs = widget.cs; + final interactive = widget.onUserTap != null; + + final bubble = Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Text.rich( + TextSpan( + children: [ + for (final segment in resolved.segments) + TextSpan( + text: segment.text, + style: interactive && segment.userId != null + ? const TextStyle(fontWeight: FontWeight.w600) + : null, + recognizer: interactive && segment.userId != null + ? _recognizerFor(segment.userId!) + : null, + ), + ], + ), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ); + + final tapUserId = resolved.tapUserId; + if (!interactive || tapUserId == null) return bubble; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => widget.onUserTap!(tapUserId), + child: bubble, + ); + } +} diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart index d1aaa44..60e69a4 100644 --- a/lib/frontend/widgets/chat_info/shared_content_tabs.dart +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -23,6 +23,7 @@ import '../../screens/chats/chat_screen.dart'; import '../custom_notification.dart'; import '../komet_avatar.dart'; import '../photo_viewer.dart'; +import '../reload_on_reconnect.dart'; import '../small_spinner.dart'; import '../swipe_route.dart'; import '../video_player_screen.dart'; @@ -312,7 +313,8 @@ class CommonChatsTab extends StatefulWidget { State createState() => _CommonChatsTabState(); } -class _CommonChatsTabState extends State { +class _CommonChatsTabState extends State + with ReloadOnReconnect { bool _loading = true; List _chats = const []; Map _onlineByChat = const {}; @@ -323,6 +325,9 @@ class _CommonChatsTabState extends State { _load(); } + @override + void reloadAfterReconnect() => _load(); + Future _load() async { final chats = await sharedContentModule.fetchCommonChats(widget.userId); @@ -474,7 +479,8 @@ class SharedMediaTab extends StatefulWidget { State createState() => _SharedMediaTabState(); } -class _SharedMediaTabState extends State { +class _SharedMediaTabState extends State + with ReloadOnReconnect { static const int _pageSize = 60; bool _loading = true; @@ -507,6 +513,9 @@ class _SharedMediaTabState extends State { } } + @override + void reloadAfterReconnect() => _load(widget.anchorMessageId, initial: true); + Future _load(String anchor, {required bool initial}) async { final page = await sharedContentModule.fetchMedia( chatId: widget.chatId, diff --git a/lib/frontend/widgets/chat_menu_overlay.dart b/lib/frontend/widgets/chat_menu_overlay.dart index 656ca06..1c1ea83 100644 --- a/lib/frontend/widgets/chat_menu_overlay.dart +++ b/lib/frontend/widgets/chat_menu_overlay.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -26,6 +28,8 @@ void showChatMenu({ required BuildContext context, required Rect anchorRect, required List items, + Widget? header, + Widget? footer, }) { final overlay = Overlay.of(context, rootOverlay: true); late OverlayEntry entry; @@ -33,6 +37,8 @@ void showChatMenu({ builder: (ctx) => _ChatMenuLayer( anchorRect: anchorRect, items: items, + header: header, + footer: footer, onDismiss: () { if (entry.mounted) entry.remove(); }, @@ -45,25 +51,72 @@ void showChatMenu({ class _ChatMenuLayer extends StatefulWidget { final Rect anchorRect; final List items; + final Widget? header; + final Widget? footer; final VoidCallback onDismiss; const _ChatMenuLayer({ required this.anchorRect, required this.items, required this.onDismiss, + this.header, + this.footer, }); @override State<_ChatMenuLayer> createState() => _ChatMenuLayerState(); } +class _MenuLayout extends SingleChildLayoutDelegate { + static const double menuWidth = 290.0; + static const double margin = 8.0; + static const double gap = 6.0; + + final Rect anchor; + final EdgeInsets safeArea; + + const _MenuLayout({required this.anchor, required this.safeArea}); + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + final width = math.min(menuWidth, constraints.maxWidth - margin * 2); + final available = + constraints.maxHeight - safeArea.top - safeArea.bottom - margin * 2; + return BoxConstraints( + minWidth: math.max(0, width), + maxWidth: math.max(0, width), + maxHeight: math.max(120.0, available), + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final maxLeft = math.max(margin, size.width - childSize.width - margin); + final left = (anchor.right - childSize.width).clamp(margin, maxLeft); + + final topLimit = safeArea.top + margin; + final bottomLimit = size.height - safeArea.bottom - margin; + final below = anchor.bottom + gap; + final above = anchor.top - gap - childSize.height; + + double top; + if (below + childSize.height <= bottomLimit) { + top = below; + } else if (above >= topLimit) { + top = above; + } else { + top = bottomLimit - childSize.height; + } + return Offset(left, math.max(topLimit, top)); + } + + @override + bool shouldRelayout(_MenuLayout oldDelegate) => + oldDelegate.anchor != anchor || oldDelegate.safeArea != safeArea; +} + class _ChatMenuLayerState extends State<_ChatMenuLayer> with SingleTickerProviderStateMixin, AnimatedOverlayPopup<_ChatMenuLayer> { - static const double _menuWidth = 290.0; - static const double _hMargin = 8.0; - static const double _vMargin = 8.0; - static const double _gap = 6.0; - @override Duration get overlayForwardDuration => const Duration(milliseconds: 220); @@ -78,29 +131,10 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> closeOverlay().then((_) => item.onTap?.call()); } - Rect _resolveRect(Size screen) { - final maxWidth = screen.width - 2 * _hMargin; - final width = maxWidth <= 0 - ? screen.width - : (_menuWidth.clamp(0.0, maxWidth)); - final maxLeft = screen.width - width - _hMargin; - double left = widget.anchorRect.right - width; - if (left > maxLeft) left = maxLeft; - if (left < _hMargin) left = _hMargin; - final top = widget.anchorRect.bottom + _gap; - return Rect.fromLTWH(left, top, width, 0); - } - @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final screen = MediaQuery.sizeOf(context); - final bottomInset = MediaQuery.paddingOf(context).bottom; - final rect = _resolveRect(screen); - final maxHeight = (screen.height - rect.top - bottomInset - _vMargin).clamp( - 120.0, - double.infinity, - ); + final safeArea = MediaQuery.paddingOf(context); return AnimatedBuilder( animation: overlayAnimation, builder: (ctx, child) { @@ -115,16 +149,19 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> child: const SizedBox.expand(), ), ), - Positioned( - left: rect.left, - top: rect.top, - width: rect.width, - child: Opacity( - opacity: t, - child: Transform.scale( - scale: scale, - alignment: Alignment.topRight, - child: child, + Positioned.fill( + child: CustomSingleChildLayout( + delegate: _MenuLayout( + anchor: widget.anchorRect, + safeArea: safeArea, + ), + child: Opacity( + opacity: t, + child: Transform.scale( + scale: scale, + alignment: Alignment.topRight, + child: child, + ), ), ), ), @@ -137,25 +174,39 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> clipBehavior: Clip.antiAlias, elevation: 12, shadowColor: Colors.black.withValues(alpha: 0.45), - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 6), - for (final item in widget.items) ...[ - _ChatMenuRow(item: item, onTap: () => _onItemTap(item)), - if (item.dividerAfter) - Divider( - height: 1, - thickness: 1, - color: cs.onSurface.withValues(alpha: 0.07), - ), - ], - const SizedBox(height: 6), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.header != null) ...[ + widget.header!, + Divider( + height: 1, + thickness: 1, + color: cs.onSurface.withValues(alpha: 0.07), + ), ], - ), + const SizedBox(height: 6), + for (final item in widget.items) ...[ + _ChatMenuRow(item: item, onTap: () => _onItemTap(item)), + if (item.dividerAfter) + Divider( + height: 1, + thickness: 1, + color: cs.onSurface.withValues(alpha: 0.07), + ), + ], + const SizedBox(height: 6), + if (widget.footer != null) ...[ + Divider( + height: 1, + thickness: 1, + color: cs.onSurface.withValues(alpha: 0.07), + ), + widget.footer!, + ], + ], ), ), ), diff --git a/lib/frontend/widgets/rightward_drag_recognizer.dart b/lib/frontend/widgets/directional_drag_recognizer.dart similarity index 67% rename from lib/frontend/widgets/rightward_drag_recognizer.dart rename to lib/frontend/widgets/directional_drag_recognizer.dart index 8ac3e81..e84d655 100644 --- a/lib/frontend/widgets/rightward_drag_recognizer.dart +++ b/lib/frontend/widgets/directional_drag_recognizer.dart @@ -1,12 +1,18 @@ import 'package:flutter/gestures.dart'; -class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { - RightwardDragRecognizer({super.debugOwner}) { +class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer { + DirectionalDragRecognizer({ + required this.direction, + this.minAcceptDistance = 20.0, + this.minAcceptVelocity, + super.debugOwner, + }) { onlyAcceptDragOnThreshold = true; } - static const double _kMinAcceptVelocity = 700.0; - static const double _kMinAcceptDistance = 20.0; + final double direction; + final double minAcceptDistance; + final double? minAcceptVelocity; final Map _initialPositions = {}; final Map _velocityTrackers = {}; @@ -30,7 +36,7 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { ); final initial = _initialPositions[event.pointer]; if (initial != null) { - final dx = event.position.dx - initial.dx; + final dx = (event.position.dx - initial.dx) * direction; _currentDeltaX[event.pointer] = dx; if (dx < -kTouchSlop) { stopTrackingPointer(event.pointer); @@ -57,10 +63,13 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { for (final dx in _currentDeltaX.values) { if (dx > maxDx) maxDx = dx; } - if (maxDx < _kMinAcceptDistance) return false; + if (maxDx < minAcceptDistance) return false; + + final minVelocity = minAcceptVelocity; + if (minVelocity == null) return true; for (final tracker in _velocityTrackers.values) { - final vx = tracker.getVelocity().pixelsPerSecond.dx; - if (vx >= _kMinAcceptVelocity) return true; + final vx = tracker.getVelocity().pixelsPerSecond.dx * direction; + if (vx >= minVelocity) return true; } return false; } @@ -83,3 +92,12 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { super.rejectGesture(pointer); } } + +class RightwardDragRecognizer extends DirectionalDragRecognizer { + RightwardDragRecognizer({super.debugOwner}) + : super(direction: 1, minAcceptVelocity: 700); +} + +class LeftwardDragRecognizer extends DirectionalDragRecognizer { + LeftwardDragRecognizer({super.debugOwner}) : super(direction: -1); +} diff --git a/lib/frontend/widgets/formatted_message_text.dart b/lib/frontend/widgets/formatted_message_text.dart index ec22c2a..b044be1 100644 --- a/lib/frontend/widgets/formatted_message_text.dart +++ b/lib/frontend/widgets/formatted_message_text.dart @@ -1,16 +1,29 @@ +import 'dart:async'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import '../../backend/modules/messages.dart' show ContactCache; import '../../core/utils/link_opener.dart'; +import '../../core/utils/text_entities.dart'; import '../../core/utils/text_format.dart'; +import '../screens/contacts/open_contact_profile.dart'; import 'link_text.dart'; import 'lottie_image.dart'; +import 'text_entity_actions.dart'; + +Color mentionTextColor(ColorScheme cs) => cs.primary; + +enum TextEntityMode { menu, copy } class FormattedMessageText extends StatefulWidget { final String text; final List ranges; final TextStyle style; final TextAlign textAlign; + final TextEntityMode entityMode; + final int? maxLines; + final TextOverflow? overflow; const FormattedMessageText({ super.key, @@ -18,18 +31,22 @@ class FormattedMessageText extends StatefulWidget { required this.ranges, required this.style, this.textAlign = TextAlign.start, + this.entityMode = TextEntityMode.menu, + this.maxLines, + this.overflow, }); static bool isFormatted(String? text, List ranges) => text != null && text.isNotEmpty && - (ranges.isNotEmpty || LinkText.hasLinks(text)); + (ranges.isNotEmpty || LinkText.hasLinks(text) || hasTextEntities(text)); static TextSpan buildInlineSpan( String text, List ranges, - TextStyle style, - ) { + TextStyle style, { + Color? mentionColor, + }) { final quoteColor = style.color?.withValues(alpha: 0.85); final segments = segmentizeFormats(text, ranges); return TextSpan( @@ -42,6 +59,7 @@ class FormattedMessageText extends StatefulWidget { style, segment.formats, quoteColor: quoteColor, + mentionColor: mentionColor, ), ), ], @@ -53,7 +71,7 @@ class FormattedMessageText extends StatefulWidget { } class _FormattedMessageTextState extends State { - final List _recognizers = []; + final List _recognizers = []; @override void dispose() { @@ -87,13 +105,113 @@ class _FormattedMessageTextState extends State { return ranges; } + void _openMention(int userId) { + unawaited( + openContactDialogProfile( + context, + contactId: userId, + name: ContactCache.get(userId) ?? 'User #$userId', + avatarUrl: ContactCache.getAvatar(userId), + ), + ); + } + + T _track(T recognizer) { + _recognizers.add(recognizer); + return recognizer; + } + + GestureRecognizer? _entityRecognizer(TextEntity entity) { + switch (entity.kind) { + case TextEntityKind.mention: + return _track( + TapGestureRecognizer() + ..onTap = () => + unawaited(openMentionProfile(context, entity.value)), + ); + case TextEntityKind.phone: + if (widget.entityMode == TextEntityMode.copy) { + return _track( + TapGestureRecognizer() + ..onTap = () => unawaited( + copyTextEntity(context, entity.value, 'Номер скопирован'), + ), + ); + } + return _track( + LongPressGestureRecognizer() + ..onLongPressStart = (details) => showPhoneEntityMenu( + context, + entity.value, + at: details.globalPosition, + ), + ); + case TextEntityKind.card: + if (widget.entityMode == TextEntityMode.copy) { + return _track( + TapGestureRecognizer() + ..onTap = () => unawaited( + copyTextEntity(context, entity.value, 'Номер карты скопирован'), + ), + ); + } + return _track( + LongPressGestureRecognizer() + ..onLongPressStart = (details) => showCardEntityMenu( + context, + entity.value, + at: details.globalPosition, + ), + ); + } + } + + List _claimedRanges(List ranges) => [ + for (final range in ranges) + if (range.format == TextFormat.link || + range.format == TextFormat.userMention) + (start: range.start, end: range.end), + ]; + + TextEntity? _entityAt(List entities, int start, int end) { + for (final entity in entities) { + if (entity.start <= start && entity.end >= end) return entity; + } + return null; + } + + List<({int start, int end})> _splitByEntities( + int start, + int end, + List entities, + ) { + final points = {start, end}; + for (final entity in entities) { + if (entity.end <= start || entity.start >= end) continue; + if (entity.start > start) points.add(entity.start); + if (entity.end < end) points.add(entity.end); + } + final sorted = points.toList()..sort(); + return [ + for (var i = 0; i < sorted.length - 1; i++) + (start: sorted[i], end: sorted[i + 1]), + ]; + } + @override Widget build(BuildContext context) { _disposeRecognizers(); - final segments = segmentizeFormats(widget.text, _withAutoLinks()); - final baseColor = widget.style.color ?? Theme.of(context).colorScheme.onSurface; + final ranges = _withAutoLinks(); + final entities = detectTextEntities( + widget.text, + skip: _claimedRanges(ranges), + ); + final segments = segmentizeFormats(widget.text, ranges); + final cs = Theme.of(context).colorScheme; + final baseColor = widget.style.color ?? cs.onSurface; final barColor = baseColor.withValues(alpha: 0.4); final quoteColor = baseColor.withValues(alpha: 0.85); + final mentionColor = mentionTextColor(cs); final spans = []; var prevQuote = false; @@ -121,6 +239,7 @@ class _FormattedMessageTextState extends State { widget.style, segment.formats, quoteColor: quoteColor, + mentionColor: mentionColor, ); final content = widget.text.substring(segment.start, segment.end); if (segment.animojiUrl != null) { @@ -153,22 +272,72 @@ class _FormattedMessageTextState extends State { ); continue; } + final mentionId = segment.mentionId; + if (mentionId != null && mentionId != 0) { + spans.add( + TextSpan( + text: content, + style: style, + recognizer: _track( + TapGestureRecognizer()..onTap = () => _openMention(mentionId), + ), + ), + ); + continue; + } + + final mentionName = segment.mentionName; + if (mentionName != null) { + spans.add( + TextSpan( + text: content, + style: style, + recognizer: _track( + TapGestureRecognizer() + ..onTap = () => + unawaited(openMentionProfile(context, mentionName)), + ), + ), + ); + continue; + } + if (segment.url != null) { final url = segment.url!; - final recognizer = TapGestureRecognizer() - ..onTap = () => openExternalUrl(context, url); - _recognizers.add(recognizer); spans.add( - TextSpan(text: content, style: style, recognizer: recognizer), + TextSpan( + text: content, + style: style, + recognizer: _track( + TapGestureRecognizer() + ..onTap = () => openExternalUrl(context, url), + ), + ), + ); + continue; + } + + for (final piece in _splitByEntities( + segment.start, + segment.end, + entities, + )) { + final entity = _entityAt(entities, piece.start, piece.end); + spans.add( + TextSpan( + text: widget.text.substring(piece.start, piece.end), + style: entity == null ? style : style.copyWith(color: mentionColor), + recognizer: entity == null ? null : _entityRecognizer(entity), + ), ); - } else { - spans.add(TextSpan(text: content, style: style)); } } return Text.rich( TextSpan(style: widget.style, children: spans), textAlign: widget.textAlign, + maxLines: widget.maxLines, + overflow: widget.overflow ?? TextOverflow.clip, ); } } diff --git a/lib/frontend/widgets/mention_suggestions_panel.dart b/lib/frontend/widgets/mention_suggestions_panel.dart new file mode 100644 index 0000000..63520de --- /dev/null +++ b/lib/frontend/widgets/mention_suggestions_panel.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import '../screens/chats/chat/mention_panel_controller.dart'; +import 'komet_avatar.dart'; +import 'small_spinner.dart'; + +class MentionSuggestionsPanel extends StatefulWidget { + final List candidates; + final double maxHeight; + final bool loadingMore; + final ValueChanged onSelected; + final VoidCallback onLoadMore; + + const MentionSuggestionsPanel({ + super.key, + required this.candidates, + required this.onSelected, + required this.onLoadMore, + this.loadingMore = false, + this.maxHeight = 220, + }); + + @override + State createState() => + _MentionSuggestionsPanelState(); +} + +class _MentionSuggestionsPanelState extends State { + final ScrollController _controller = ScrollController(); + + @override + void initState() { + super.initState(); + _controller.addListener(_onScroll); + } + + @override + void dispose() { + _controller.removeListener(_onScroll); + _controller.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_controller.hasClients) return; + final position = _controller.position; + if (position.pixels >= position.maxScrollExtent - 120) { + widget.onLoadMore(); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final candidates = widget.candidates; + + return Material( + type: MaterialType.transparency, + child: Container( + constraints: BoxConstraints(maxHeight: widget.maxHeight), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), + ), + clipBehavior: Clip.antiAlias, + child: candidates.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Center( + child: SmallSpinner(size: 20, color: cs.onSurfaceVariant), + ), + ) + : ListView.separated( + controller: _controller, + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 6), + itemCount: candidates.length + (widget.loadingMore ? 1 : 0), + separatorBuilder: (_, _) => Divider( + height: 1, + thickness: 1, + indent: 14, + endIndent: 14, + color: cs.outlineVariant.withValues(alpha: 0.18), + ), + itemBuilder: (context, i) { + if (i >= candidates.length) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Center( + child: SmallSpinner( + size: 18, + color: cs.onSurfaceVariant, + ), + ), + ); + } + final candidate = candidates[i]; + return InkWell( + onTap: () => widget.onSelected(candidate), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + child: Row( + children: [ + KometAvatar( + name: candidate.name, + imageUrl: candidate.avatarUrl, + size: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + candidate.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 2208b1d..a169183 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -27,6 +27,7 @@ import 'attachment/bubbles/bubble_context.dart'; import 'attachment/bubbles/poll_bubble.dart'; import 'attachment/bubbles/share_bubble.dart'; import 'attachment/bubbles/call_bubble.dart'; +import 'attachment/bubbles/control_bubble.dart'; import 'attachment/bubbles/location_bubble.dart'; import 'attachment/bubbles/contact_bubble.dart'; import 'attachment/bubbles/sticker_bubble.dart'; @@ -282,6 +283,14 @@ class MessageBubble extends StatelessWidget { return photoCount >= 2 && !hasCaption; } + bool get _showsSenderName => + !isMe && + chatType == "CHAT" && + prevMessage?.senderId != message.senderId; + + bool get _stretchesTextRow => + message.replyInfo != null || _showsSenderName; + BubbleShape _computeShape() { if (message.isControl) return BubbleShape.singleMiddle; @@ -589,10 +598,7 @@ class MessageBubble extends StatelessWidget { showAvatarSlot && chatType == "CHAT" && nextMessage?.senderId != message.senderId; - final showSenderName = - showAvatarSlot && - chatType == "CHAT" && - prevMessage?.senderId != message.senderId; + final showSenderName = _showsSenderName; final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0); final keyboard = _inlineKeyboard; @@ -635,51 +641,60 @@ class MessageBubble extends StatelessWidget { final reactionsInside = contentType != MessageType.text && !reactionsUnder; final reply = message.replyInfo; - Widget withReply(Widget content) { - if (reply == null) return content; - final quote = _buildReplyQuote(context, cs, textColor, reply); - if (contentType != MessageType.text || jumboAnimoji != null) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [quote, const SizedBox(height: 4), content], - ); - } - return IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _ZeroIntrinsicWidth(child: quote), - const SizedBox(height: 4), - content, - ], - ), - ); - } final bool hasCommentsFooter = onCommentsTap != null; final EdgeInsets containerPadding = hasCommentsFooter ? EdgeInsets.zero : padding; - final Widget innerContent = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showSenderName) - _buildSenderHeader(cs, padding == EdgeInsets.zero), - withReply( - reactionsInside - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [bubbleContent, _reactionsBar(cs)], - ) - : bubbleContent, - ), - ], - ); + final Widget contentWithReactions = reactionsInside + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [bubbleContent, _reactionsBar(cs)], + ) + : bubbleContent; + + final Widget? senderHeader = showSenderName + ? _buildSenderHeader(cs, padding == EdgeInsets.zero) + : null; + + final Widget innerContent = + contentType == MessageType.text && + jumboAnimoji == null && + _stretchesTextRow + ? IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (senderHeader != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: senderHeader, + ), + if (reply != null) ...[ + _ZeroIntrinsicWidth( + child: _buildReplyQuote(context, cs, textColor, reply), + ), + const SizedBox(height: 4), + ], + contentWithReactions, + ], + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ?senderHeader, + if (reply != null) ...[ + _buildReplyQuote(context, cs, textColor, reply), + const SizedBox(height: 4), + ], + contentWithReactions, + ], + ); final Widget bubbleBox = ListenableBuilder( listenable: Listenable.merge([ @@ -1199,66 +1214,12 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildControlContent(ColorScheme cs) { - final attachments = message.attachments; - if (attachments == null || attachments.isEmpty) { - return const SizedBox.shrink(); - } - - final control = attachments.first; - if (control is! ControlAttachment) return const SizedBox.shrink(); - - String? text; - switch (control.event) { - case 'system': - text = control.title; - break; - case 'new': - text = - '${ContactCache.get(message.senderId) ?? 'Пользователь'} создал(а) чат'; - break; - case 'add': - final names = (control.userIds ?? []) - .map((id) => ContactCache.get(id) ?? 'Пользователь') - .join(', '); - text = - '${ContactCache.get(message.senderId) ?? 'Пользователь'} добавил(а) $names'; - break; - case 'leave': - text = - '${ContactCache.get(message.senderId) ?? 'Пользователь'} покинул(а) чат'; - break; - case 'joinByLink': - text = - '${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату'; - break; - case 'pin': - text = - '${ContactCache.get(message.senderId) ?? 'Пользователь'} закрепил(а) сообщение'; - break; - default: - text = control.title; - } - - if (text == null || text.isEmpty) return const SizedBox.shrink(); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - text, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - fontStyle: FontStyle.italic, - ), - textAlign: TextAlign.center, - ), - ); - } + Widget _buildControlContent(ColorScheme cs) => ControlBubble( + key: ValueKey('control_${message.id}'), + message: message, + cs: cs, + onUserTap: onAvatarTap, + ); Widget _wrapSelectable(Widget textWidget) { final listenable = textSelection; @@ -1366,7 +1327,9 @@ class MessageBubble extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ - Flexible(child: textWidget), + _stretchesTextRow + ? Expanded(child: textWidget) + : Flexible(child: textWidget), const SizedBox(width: 8), Padding( padding: const EdgeInsets.only(bottom: 2), diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index 9af43f5..5163bd6 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -121,7 +121,10 @@ class _PhotoViewerScreenState extends State { void initState() { super.initState(); _items = _localItems(); - _index = widget.initialIndex.clamp(0, _items.length - 1); + _index = (_items.length - 1 - widget.initialIndex).clamp( + 0, + _items.length - 1, + ); _controller = PageController(initialPage: _index); unawaited(_loadFeed()); } @@ -135,7 +138,7 @@ class _PhotoViewerScreenState extends State { List<_ViewerPhoto> _localItems() { final message = widget.message; return [ - for (var i = 0; i < widget.photos.length; i++) + for (var i = widget.photos.length - 1; i >= 0; i--) _ViewerPhoto( id: _localId(widget.photos[i], message, i), photo: widget.photos[i], @@ -147,6 +150,23 @@ class _PhotoViewerScreenState extends State { ]; } + List<_ViewerPhoto> _feedItems(List items) { + final out = <_ViewerPhoto>[]; + var start = 0; + while (start < items.length) { + var end = start; + while (end + 1 < items.length && + items[end + 1].messageId == items[start].messageId) { + end++; + } + for (var i = end; i >= start; i--) { + out.add(_ViewerPhoto.fromFeed(items[i])); + } + start = end + 1; + } + return out; + } + String _localId(PhotoAttachment photo, CachedMessage? message, int at) { final key = _feedKey(photo, message); return key ?? 'local:${message?.id ?? ''}:$at'; @@ -182,7 +202,7 @@ class _PhotoViewerScreenState extends State { return; } - final items = feed.items.map(_ViewerPhoto.fromFeed).toList(); + final items = _feedItems(feed.items); final at = items.indexWhere((i) => i.id == key); if (at == -1) { setState(() => _feedFailed = true); @@ -224,7 +244,7 @@ class _PhotoViewerScreenState extends State { ); if (!mounted) return; - final items = feed.items.map(_ViewerPhoto.fromFeed).toList(); + final items = _feedItems(feed.items); final at = items.indexWhere((i) => i.id == _current.id); if (at == -1) { setState(() { @@ -413,8 +433,8 @@ class _PhotoViewerScreenState extends State { backgroundColor: Colors.black, body: CallbackShortcuts( bindings: { - const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(-1), - const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(1), + const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(1), + const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(-1), }, child: Focus( autofocus: true, @@ -424,6 +444,7 @@ class _PhotoViewerScreenState extends State { child: PageView.builder( key: ValueKey(_pager), controller: _controller, + reverse: true, itemCount: _items.length, onPageChanged: _onPageChanged, itemBuilder: (_, i) => GestureDetector( @@ -451,15 +472,18 @@ class _PhotoViewerScreenState extends State { curve: Curves.easeOut, child: Stack( children: [ - if (_index > 0) - Align( - alignment: Alignment.centerLeft, - child: _arrow(Symbols.chevron_left, () => _step(-1)), - ), if (_index < _items.length - 1) + Align( + alignment: Alignment.centerLeft, + child: _arrow(Symbols.chevron_left, () => _step(1)), + ), + if (_index > 0) Align( alignment: Alignment.centerRight, - child: _arrow(Symbols.chevron_right, () => _step(1)), + child: _arrow( + Symbols.chevron_right, + () => _step(-1), + ), ), Positioned( top: padding.top + 8, diff --git a/lib/frontend/widgets/reload_on_reconnect.dart b/lib/frontend/widgets/reload_on_reconnect.dart new file mode 100644 index 0000000..ac2ab62 --- /dev/null +++ b/lib/frontend/widgets/reload_on_reconnect.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../../backend/api.dart'; +import '../../main.dart' show api; + +mixin ReloadOnReconnect on State { + StreamSubscription? _reconnectSub; + int _reloadedEpoch = api.sessionEpoch; + + void reloadAfterReconnect(); + + @override + void initState() { + super.initState(); + _reconnectSub = api.stateStream.listen(_onSessionState); + } + + @override + void dispose() { + _reconnectSub?.cancel(); + super.dispose(); + } + + void _onSessionState(SessionState state) { + if (state != SessionState.online) return; + if (api.sessionEpoch == _reloadedEpoch) return; + _reloadedEpoch = api.sessionEpoch; + if (mounted) reloadAfterReconnect(); + } +} diff --git a/lib/frontend/widgets/rich_message_controller.dart b/lib/frontend/widgets/rich_message_controller.dart index 70a82ba..65a1564 100644 --- a/lib/frontend/widgets/rich_message_controller.dart +++ b/lib/frontend/widgets/rich_message_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../core/utils/text_format.dart'; import '../../models/animoji.dart'; +import 'formatted_message_text.dart'; import 'lottie_image.dart'; const List composerFormats = [ @@ -18,6 +19,13 @@ class _Interval { _Interval(this.start, this.end); } +class _MentionEntity { + int start; + int end; + final int userId; + _MentionEntity(this.start, this.end, this.userId); +} + class _AnimojiEntity { final int uid; int offset; @@ -39,6 +47,7 @@ class RichMessageController extends TextEditingController { final Map> _intervals = {}; final List<_AnimojiEntity> _animoji = []; + final List<_MentionEntity> _mentions = []; int _entitySeq = 0; RichMessageController({super.text}); @@ -73,6 +82,27 @@ class RichMessageController extends TextEditingController { notifyListeners(); } + void insertMention({ + required int userId, + required String name, + required int start, + required int end, + }) { + final oldText = value.text; + if (start < 0 || end > oldText.length || start > end || name.isEmpty) { + return; + } + final inserted = '$name '; + value = TextEditingValue( + text: oldText.replaceRange(start, end, inserted), + selection: TextSelection.collapsed(offset: start + inserted.length), + ); + + _mentions.add(_MentionEntity(start, start + name.length, userId)); + _mentions.sort((a, b) => a.start.compareTo(b.start)); + notifyListeners(); + } + ({String text, List> elements}) buildContent() { final src = value.text; if (_animoji.isEmpty) { @@ -121,6 +151,7 @@ class RichMessageController extends TextEditingController { 'type': textFormatToServer(range.format), 'from': from, 'length': to - from, + if (range.entityId != null) 'entityId': range.entityId, }); } return (text: glyphText, elements: elements); @@ -136,7 +167,8 @@ class RichMessageController extends TextEditingController { super.value = newValue; } - bool get hasFormatting => _intervals.values.any((list) => list.isNotEmpty); + bool get hasFormatting => + _intervals.values.any((list) => list.isNotEmpty) || _mentions.isNotEmpty; void clearFormatting() { if (_intervals.isEmpty) return; @@ -146,12 +178,21 @@ class RichMessageController extends TextEditingController { void setFormatRanges(Iterable ranges) { _intervals.clear(); + _mentions.clear(); for (final range in ranges) { + if (range.format == TextFormat.userMention) { + final userId = range.entityId; + if (userId != null) { + _mentions.add(_MentionEntity(range.start, range.end, userId)); + } + continue; + } if (!composerFormats.contains(range.format)) continue; _intervals .putIfAbsent(range.format, () => []) .add(_Interval(range.start, range.end)); } + _mentions.sort((a, b) => a.start.compareTo(b.start)); for (final list in _intervals.values) { _normalize(list); } @@ -175,6 +216,16 @@ class RichMessageController extends TextEditingController { ); } }); + for (final mention in _mentions) { + ranges.add( + FormatRange( + format: TextFormat.userMention, + start: mention.start, + length: mention.end - mention.start, + entityId: mention.userId, + ), + ); + } return ranges; } @@ -200,7 +251,7 @@ class RichMessageController extends TextEditingController { } void _remap(String oldText, String newText) { - if (_intervals.isEmpty && _animoji.isEmpty) return; + if (_intervals.isEmpty && _animoji.isEmpty && _mentions.isEmpty) return; final oldLen = oldText.length; final newLen = newText.length; @@ -240,6 +291,16 @@ class RichMessageController extends TextEditingController { } } + if (_mentions.isNotEmpty) { + _mentions.removeWhere( + (mention) => changeStart < mention.end && oldChangeEnd > mention.start, + ); + for (final mention in _mentions) { + mention.start = mapStart(mention.start); + mention.end = mapEnd(mention.end); + } + } + final empty = []; _intervals.forEach((format, list) { for (final interval in list) { @@ -325,6 +386,7 @@ class RichMessageController extends TextEditingController { final ranges = _toFormatRanges(); final baseColor = baseStyle.color; final quoteColor = baseColor?.withValues(alpha: 0.85); + final mentionColor = mentionTextColor(Theme.of(context).colorScheme); final segments = segmentizeFormats(content, ranges); final entityByOffset = {for (final e in _animoji) e.offset: e}; final box = (baseStyle.fontSize ?? 16) * 1.4; @@ -335,6 +397,7 @@ class RichMessageController extends TextEditingController { baseStyle, segment.formats, quoteColor: quoteColor, + mentionColor: mentionColor, ); var runStart = segment.start; var i = segment.start; diff --git a/lib/frontend/widgets/swipe_route.dart b/lib/frontend/widgets/swipe_route.dart index c13bcd2..c0002c5 100644 --- a/lib/frontend/widgets/swipe_route.dart +++ b/lib/frontend/widgets/swipe_route.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:flutter/cupertino.dart'; -import 'rightward_drag_recognizer.dart'; +import 'directional_drag_recognizer.dart'; class SwipeRoute extends PageRoute { SwipeRoute({ diff --git a/lib/frontend/widgets/swipe_to_pop.dart b/lib/frontend/widgets/swipe_to_pop.dart index ea20bf8..b7481a1 100644 --- a/lib/frontend/widgets/swipe_to_pop.dart +++ b/lib/frontend/widgets/swipe_to_pop.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'rightward_drag_recognizer.dart'; +import 'directional_drag_recognizer.dart'; class SwipeToPop extends StatefulWidget { final Widget child; diff --git a/lib/frontend/widgets/text_entity_actions.dart b/lib/frontend/widgets/text_entity_actions.dart new file mode 100644 index 0000000..d1e9e94 --- /dev/null +++ b/lib/frontend/widgets/text_entity_actions.dart @@ -0,0 +1,210 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../backend/modules/contacts.dart'; +import '../../core/utils/text_entities.dart'; +import '../../main.dart' show api; +import 'chat_menu_overlay.dart'; +import 'custom_notification.dart'; +import 'komet_avatar.dart'; +import 'max_link_handler.dart'; +import 'small_spinner.dart'; + +Future openMentionProfile(BuildContext context, String nickname) async { + final handled = await tryHandleMaxLink(context, 'https://max.ru/$nickname'); + if (handled || !context.mounted) return; + showCustomNotification(context, 'Профиль @$nickname не найден'); +} + +Future copyTextEntity( + BuildContext context, + String value, + String message, +) async { + await Clipboard.setData(ClipboardData(text: value)); + if (!context.mounted) return; + showCustomNotification(context, message); +} + +void showPhoneEntityMenu( + BuildContext context, + String phone, { + required Offset at, +}) { + showChatMenu( + context: context, + anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0), + header: _PhoneOwnerHeader(phone: phone), + items: [ + ChatMenuItem( + icon: Symbols.content_copy, + label: 'Скопировать номер телефона', + onTap: () => copyTextEntity(context, phone, 'Номер скопирован'), + ), + if (defaultTargetPlatform == TargetPlatform.android) + ChatMenuItem( + icon: Symbols.call, + label: 'Позвонить', + onTap: () => _dial(context, phone), + ), + ], + ); +} + +void showCardEntityMenu( + BuildContext context, + String digits, { + required Offset at, +}) { + showChatMenu( + context: context, + anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0), + items: [ + ChatMenuItem( + icon: Symbols.content_copy, + label: 'Скопировать номер карты', + onTap: () => copyTextEntity(context, digits, 'Номер карты скопирован'), + ), + ], + footer: _CardFooter(digits: digits), + ); +} + +Future _dial(BuildContext context, String phone) async { + final uri = Uri(scheme: 'tel', path: phone); + var launched = false; + try { + launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + launched = false; + } + if (launched || !context.mounted) return; + showCustomNotification(context, 'Не удалось открыть приложение звонков'); +} + +class _CardFooter extends StatelessWidget { + final String digits; + + const _CardFooter({required this.digits}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final title = cardBrandTitle(digits); + return Padding( + padding: const EdgeInsets.fromLTRB(18, 12, 18, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cardMask(digits), + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + if (title != null) ...[ + const SizedBox(height: 2), + Text( + title, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ], + ), + ); + } +} + +class _PhoneOwnerHeader extends StatefulWidget { + final String phone; + + const _PhoneOwnerHeader({required this.phone}); + + @override + State<_PhoneOwnerHeader> createState() => _PhoneOwnerHeaderState(); +} + +class _PhoneOwnerHeaderState extends State<_PhoneOwnerHeader> { + late final Future _lookup = ContactsModule.findByPhone( + api, + widget.phone, + silent: true, + ); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return FutureBuilder( + future: _lookup, + builder: (context, snapshot) { + final Widget content; + if (snapshot.connectionState != ConnectionState.done) { + content = Row( + children: [ + SmallSpinner(size: 18, color: cs.onSurfaceVariant), + const SizedBox(width: 12), + Text( + widget.phone, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ], + ); + } else { + final found = snapshot.data; + content = found == null + ? Text( + 'Человека ещё нет в MAX', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ) + : _OwnerRow(found: found, phone: widget.phone); + } + return Padding( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 12), + child: content, + ); + }, + ); + } +} + +class _OwnerRow extends StatelessWidget { + final PhoneLookupResult found; + final String phone; + + const _OwnerRow({required this.found, required this.phone}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final resolved = found.name; + final name = (resolved == null || resolved.isEmpty) ? phone : resolved; + return Row( + children: [ + KometAvatar(name: name, size: 36, imageUrl: found.avatarUrl), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + Text( + phone, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/models/contact_info.dart b/lib/models/contact_info.dart index b411000..9184156 100644 --- a/lib/models/contact_info.dart +++ b/lib/models/contact_info.dart @@ -16,11 +16,17 @@ class ContactName { String? get label { final n = name; if (n != null && n.trim().isNotEmpty) return n.trim(); + return fullName; + } + + String? get fullName { final combined = [firstName, lastName] .where((s) => s != null && s.trim().isNotEmpty) .map((s) => s!.trim()) .join(' '); - return combined.isEmpty ? null : combined; + if (combined.isNotEmpty) return combined; + final n = name; + return (n != null && n.trim().isNotEmpty) ? n.trim() : null; } } @@ -52,6 +58,23 @@ class ContactInfo { return firstLabel; } + String? get customFullName => _fullNameOfType('CUSTOM'); + + String? get onemeFullName => _fullNameOfType('ONEME'); + + String? get fullName => customFullName ?? onemeFullName ?? displayName; + + bool get isSavedContact => customFullName != null; + + String? _fullNameOfType(String type) { + for (final n in names) { + if (n.type != type) continue; + final full = n.fullName; + if (full != null) return full; + } + return null; + } + String? get firstName { for (final n in names) { final f = n.firstName; diff --git a/test/mention_test.dart b/test/mention_test.dart new file mode 100644 index 0000000..a122855 --- /dev/null +++ b/test/mention_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:komet/core/utils/text_format.dart'; +import 'package:komet/models/contact_info.dart'; +import 'package:komet/frontend/screens/chats/chat/mention_panel_controller.dart'; +import 'package:komet/frontend/widgets/rich_message_controller.dart'; + +void main() { + group('mentionQueryAt', () { + test('detects a bare @ at the start', () { + final q = mentionQueryAt('@', 1)!; + expect(q.start, 0); + expect(q.end, 1); + expect(q.text, ''); + }); + + test('detects a query after a space', () { + final q = mentionQueryAt('hi @ал', 6)!; + expect(q.start, 3); + expect(q.end, 6); + expect(q.text, 'ал'); + }); + + test('ignores an @ glued to a preceding word', () { + expect(mentionQueryAt('mail@ya', 7), isNull); + }); + + test('ignores a token that already contains a space', () { + expect(mentionQueryAt('@ал ексей', 9), isNull); + }); + + test('ignores text without an @ before the caret', () { + expect(mentionQueryAt('привет', 6), isNull); + }); + }); + + group('RichMessageController mentions', () { + test('insertMention replaces the token and emits USER_MENTION', () { + final c = RichMessageController(); + c.value = const TextEditingValue( + text: '@ал', + selection: TextSelection.collapsed(offset: 3), + ); + final query = mentionQueryAt(c.text, 3)!; + c.insertMention( + userId: 3079465, + name: 'Алексей Поляков', + start: query.start, + end: query.end, + ); + c.value = TextEditingValue( + text: '${c.text}test', + selection: TextSelection.collapsed(offset: c.text.length + 4), + ); + + final content = c.buildContent(); + expect(content.text, 'Алексей Поляков test'); + expect(content.elements, [ + {'type': 'USER_MENTION', 'from': 0, 'length': 15, 'entityId': 3079465}, + ]); + }); + + test('editing inside a mention drops it', () { + final c = RichMessageController(); + c.value = const TextEditingValue( + text: '@a', + selection: TextSelection.collapsed(offset: 2), + ); + c.insertMention(userId: 42, name: 'Иван', start: 0, end: 2); + expect(c.buildContent().elements, hasLength(1)); + + c.value = const TextEditingValue( + text: 'Ив ', + selection: TextSelection.collapsed(offset: 2), + ); + expect(c.buildContent().elements, isEmpty); + }); + + test('text typed before a mention shifts its offset', () { + final c = RichMessageController(); + c.value = const TextEditingValue( + text: '@a', + selection: TextSelection.collapsed(offset: 2), + ); + c.insertMention(userId: 42, name: 'Иван', start: 0, end: 2); + c.value = const TextEditingValue( + text: 'эй, Иван ', + selection: TextSelection.collapsed(offset: 4), + ); + + final element = c.buildContent().elements.single; + expect(element['from'], 4); + expect(element['length'], 4); + expect(element['entityId'], 42); + }); + + test('setFormatRanges restores mentions for editing', () { + final c = RichMessageController(text: 'Иван привет'); + c.setFormatRanges(const [ + FormatRange( + format: TextFormat.userMention, + start: 0, + length: 4, + entityId: 42, + ), + ]); + + expect(c.buildContent().elements, [ + {'type': 'USER_MENTION', 'from': 0, 'length': 4, 'entityId': 42}, + ]); + }); + }); + + group('ContactInfo names', () { + ContactInfo info(List> names) => + ContactInfo.fromMap({'id': 1, 'names': names}); + + test('full name joins first and last, not the short name field', () { + final contact = info([ + { + 'name': 'Светлана', + 'firstName': 'Светлана', + 'lastName': 'Михайловна', + 'type': 'CUSTOM', + }, + { + 'name': 'Светлана', + 'firstName': 'Светлана', + 'lastName': '', + 'type': 'ONEME', + }, + ]); + + expect(contact.fullName, 'Светлана Михайловна'); + expect(contact.isSavedContact, isTrue); + }); + + test('a non-contact falls back to the ONEME name', () { + final contact = info([ + { + 'name': 'Алексей', + 'firstName': 'Алексей', + 'lastName': 'Поляков', + 'type': 'ONEME', + }, + ]); + + expect(contact.fullName, 'Алексей Поляков'); + expect(contact.isSavedContact, isFalse); + }); + + test('a custom name wins over the oneme one', () { + final contact = info([ + {'firstName': 'Лёша', 'lastName': 'сосед', 'type': 'CUSTOM'}, + {'firstName': 'Алексей', 'lastName': 'Поляков', 'type': 'ONEME'}, + ]); + + expect(contact.fullName, 'Лёша сосед'); + }); + }); + + group('parseFormatElements', () { + test('reads a server USER_MENTION without an explicit from', () { + final ranges = parseFormatElements([ + {'entityId': 3079465, 'type': 'USER_MENTION', 'length': 15}, + ]); + expect(ranges.single.format, TextFormat.userMention); + expect(ranges.single.start, 0); + expect(ranges.single.length, 15); + expect(ranges.single.entityId, 3079465); + }); + + test('segmentizeFormats carries the mention id onto its segment', () { + final segments = segmentizeFormats('Алексей Поляков test', const [ + FormatRange( + format: TextFormat.userMention, + start: 0, + length: 15, + entityId: 3079465, + ), + ]); + expect(segments.first.mentionId, 3079465); + expect(segments.last.mentionId, isNull); + }); + }); +} diff --git a/test/message_bubble_layout_test.dart b/test/message_bubble_layout_test.dart new file mode 100644 index 0000000..d4ea2b7 --- /dev/null +++ b/test/message_bubble_layout_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +const int _me = 1; +const int _peer = 7; + +CachedMessage _message({ + required String text, + bool withReply = false, + int senderId = _peer, +}) => CachedMessage( + id: '1', + accountId: _me, + chatId: 2, + senderId: senderId, + text: text, + time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch, + status: 'sent', + payload: withReply + ? { + 'link': { + 'type': 'REPLY', + 'message': { + 'id': '9', + 'sender': _me, + 'text': 'Алексей Поляков написал очень длинный ответ', + 'time': 0, + 'attaches': [], + }, + }, + } + : null, +); + +Future _pumpBubble(WidgetTester tester, CachedMessage message) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: MessageBubble( + message: message, + isMe: false, + myId: _me, + chatType: 'CHAT', + ), + ), + ), + ), + ); + await tester.pump(); +} + +Rect _rectOf(WidgetTester tester, Finder finder) { + final size = tester.getSize(finder); + final topLeft = tester.getTopLeft(finder); + return topLeft & size; +} + +void main() { + setUp(() => ContactCache.put(_peer, 'Алексей Поляков123')); + + testWidgets('a long sender name pushes the clock to the bubble edge', ( + tester, + ) async { + await _pumpBubble(tester, _message(text: 'нет')); + + final header = _rectOf(tester, find.text('Алексей Поляков123')); + final clock = _rectOf(tester, find.textContaining('05:46')); + final body = _rectOf(tester, find.text('нет')); + + expect(header.width, greaterThan(body.width + clock.width)); + expect(clock.right, closeTo(header.right, 1)); + }); + + testWidgets('the reply quote fills the width the sender name opened up', ( + tester, + ) async { + await _pumpBubble(tester, _message(text: 'нет', withReply: true)); + + final header = _rectOf(tester, find.text('Алексей Поляков123')); + final label = _rectOf(tester, find.text('Вы')); + final quote = _rectOf( + tester, + find + .ancestor(of: find.text('Вы'), matching: find.byType(Container)) + .first, + ); + final clock = _rectOf(tester, find.textContaining('05:46')); + + expect(quote.right, greaterThan(label.right)); + expect(quote.right, closeTo(header.right, 1)); + expect(clock.right, closeTo(header.right, 1)); + }); + + testWidgets('a bubble without a header or reply still hugs its text', ( + tester, + ) async { + await _pumpBubble(tester, _message(text: 'нет', senderId: 404)); + + final clock = _rectOf(tester, find.textContaining('05:46')); + final body = _rectOf(tester, find.text('нет')); + + expect(clock.left, closeTo(body.right + 8, 1)); + }); +} diff --git a/test/text_entities_test.dart b/test/text_entities_test.dart new file mode 100644 index 0000000..366e3c5 --- /dev/null +++ b/test/text_entities_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/utils/text_entities.dart'; + +void main() { + group('detectTextEntities', () { + test('finds a phone and a card in one message', () { + final found = detectTextEntities('+79231234567 тест 2200123456789019'); + + expect(found, hasLength(2)); + expect(found.first.kind, TextEntityKind.phone); + expect(found.first.value, '+79231234567'); + expect(found.last.kind, TextEntityKind.card); + expect(found.last.value, '2200123456789019'); + }); + + test('finds a bare russian phone and a spaced card', () { + final found = detectTextEntities('89231234567 и 2200 1234 5678 9019'); + expect(found.map((e) => e.kind), [ + TextEntityKind.phone, + TextEntityKind.card, + ]); + expect(found.first.value, '+89231234567'); + expect(found.last.value, '2200123456789019'); + }); + + test('ignores digits that are not a valid card', () { + expect(detectTextEntities('116984447620359334'), isEmpty); + expect(detectTextEntities('2200123456789018'), isEmpty); + expect(detectTextEntities('1234567890123456'), isEmpty); + }); + + test('ignores timestamps and short numbers', () { + expect(detectTextEntities('05:46:16 1785041009832'), isEmpty); + }); + + test('finds a nickname but not an email', () { + final found = detectTextEntities('привет @GroupGuardBot и mail@ya.ru'); + expect(found, hasLength(1)); + expect(found.single.kind, TextEntityKind.mention); + expect(found.single.value, 'GroupGuardBot'); + expect(found.single.start, 7); + expect(found.single.end, 21); + }); + + test('finds a formatted profile phone', () { + final found = detectTextEntities('+7 (923) 123-45-67'); + expect(found, hasLength(1)); + expect(found.single.kind, TextEntityKind.phone); + expect(found.single.value, '+79231234567'); + }); + + test('skips ranges that are already claimed', () { + const text = 'https://max.ru/GroupGuardBot'; + expect( + detectTextEntities(text, skip: [(start: 0, end: text.length)]), + isEmpty, + ); + }); + }); + + group('card metadata', () { + test('recognises payment systems by BIN', () { + expect(cardBrand('2200123456789019'), 'MIR'); + expect(cardBrand('4111111111111111'), 'VISA'); + expect(cardBrand('5500000000000004'), 'MASTERCARD'); + expect(cardBrand('340000000000009'), 'AMEX'); + expect(cardBrand('6200000000000005'), 'UNIONPAY'); + expect(cardBrand('1234567890123456'), isNull); + }); + + test('builds the mask shown in the action menu', () { + expect(cardMask('2200123456789019'), 'MIR*9019'); + expect(cardBrandTitle('2200123456789019'), 'МИР'); + }); + + test('formats a card number in groups of four', () { + expect(formatCardNumber('2200123456789019'), '2200 1234 5678 9019'); + }); + + test('luhn rejects a corrupted number', () { + expect(isLuhnValid('2200123456789019'), isTrue); + expect(isLuhnValid('2200123456789018'), isFalse); + }); + }); +} diff --git a/test/text_entity_render_test.dart b/test/text_entity_render_test.dart new file mode 100644 index 0000000..4e79a9a --- /dev/null +++ b/test/text_entity_render_test.dart @@ -0,0 +1,204 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/formatted_message_text.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/frontend/widgets/text_entity_actions.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +const String _sample = '+79231234567 тест 2200123456789019 @GroupGuardBot'; + +CachedMessage _message(String text) => CachedMessage( + id: '1', + accountId: 1, + chatId: 2, + senderId: 1, + text: text, + time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch, + status: 'sent', +); + +Future _pump(WidgetTester tester, Widget child) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align(alignment: Alignment.topLeft, child: child), + ), + ), + ); + await tester.pump(); +} + +TextSpan? _spanWithText(WidgetTester tester, String text) { + TextSpan? found; + for (final widget in tester.widgetList(find.byType(RichText))) { + widget.text.visitChildren((span) { + if (span is TextSpan && span.text == text) { + found = span; + return false; + } + return true; + }); + if (found != null) break; + } + return found; +} + +void main() { + testWidgets('a bubble highlights the phone, the card and the nickname', ( + tester, + ) async { + await _pump( + tester, + MessageBubble( + message: _message(_sample), + isMe: false, + myId: 1, + chatType: 'DIALOG', + ), + ); + + final accent = ThemeData().colorScheme.primary; + final phone = _spanWithText(tester, '+79231234567'); + final card = _spanWithText(tester, '2200123456789019'); + final mention = _spanWithText(tester, '@GroupGuardBot'); + final plain = _spanWithText(tester, ' тест '); + + expect(phone?.style?.color, accent); + expect(card?.style?.color, accent); + expect(mention?.style?.color, accent); + expect(plain?.style?.color, isNot(accent)); + + expect(phone?.recognizer, isA()); + expect(card?.recognizer, isA()); + expect(mention?.recognizer, isA()); + }); + + testWidgets('a server USER_MENTION by name opens the profile on tap', ( + tester, + ) async { + final message = CachedMessage( + id: '2', + accountId: 1, + chatId: 2, + senderId: 1, + text: '@GroupGuardBot test', + time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch, + status: 'sent', + payload: const { + 'elements': [ + {'entityName': 'GroupGuardBot', 'type': 'USER_MENTION', 'length': 14}, + ], + }, + ); + + await _pump( + tester, + MessageBubble(message: message, isMe: false, myId: 1, chatType: 'DIALOG'), + ); + + final mention = _spanWithText(tester, '@GroupGuardBot'); + expect(mention?.style?.color, ThemeData().colorScheme.primary); + expect(mention?.recognizer, isA()); + }); + + testWidgets('copy mode taps instead of opening a menu', (tester) async { + await _pump( + tester, + FormattedMessageText( + text: _sample, + ranges: const [], + entityMode: TextEntityMode.copy, + style: const TextStyle(fontSize: 16), + ), + ); + + expect( + _spanWithText(tester, '+79231234567')?.recognizer, + isA(), + ); + expect( + _spanWithText(tester, '2200123456789019')?.recognizer, + isA(), + ); + }); + + Future openMenuAt(WidgetTester tester, Offset at) async { + await _pump( + tester, + Builder( + builder: (context) => TextButton( + onPressed: () => + showCardEntityMenu(context, '2200123456789019', at: at), + child: const Text('open'), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + Rect menuRect(WidgetTester tester) => tester.getRect( + find + .ancestor( + of: find.text('Скопировать номер карты'), + matching: find.byType(SingleChildScrollView), + ) + .first, + ); + + testWidgets('a menu opened near the bottom flips above the anchor', ( + tester, + ) async { + await openMenuAt(tester, const Offset(200, 940)); + + final screen = tester.view.physicalSize / tester.view.devicePixelRatio; + final rect = menuRect(tester); + + expect(rect.bottom, lessThanOrEqualTo(screen.height - 8)); + expect(rect.bottom, lessThan(940)); + expect(rect.top, greaterThanOrEqualTo(8)); + }); + + testWidgets('a menu opened near the top stays below the anchor', ( + tester, + ) async { + await openMenuAt(tester, const Offset(200, 100)); + + final rect = menuRect(tester); + expect(rect.top, greaterThanOrEqualTo(100)); + }); + + testWidgets('the card menu shows the copy action and the card mask', ( + tester, + ) async { + await _pump( + tester, + Builder( + builder: (context) => TextButton( + onPressed: () => showCardEntityMenu( + context, + '2200123456789019', + at: const Offset(200, 300), + ), + child: const Text('open'), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Скопировать номер карты'), findsOneWidget); + expect(find.text('MIR*9019'), findsOneWidget); + expect(find.text('МИР'), findsOneWidget); + }); +}