From 48451c5d6c586ac8c40539a867d9284388cebdcc Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 3 Jul 2026 13:45:49 +0700 Subject: [PATCH] =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 51 +++- lib/backend/modules/messages.dart | 6 +- lib/core/storage/app_database.dart | 8 +- lib/core/utils/text_format.dart | 204 +++++++++++++++ .../screens/chats/chat_list_screen.dart | 67 +++-- lib/frontend/screens/chats/chat_screen.dart | 142 ++++++++++- .../widgets/formatted_message_text.dart | 143 +++++++++++ lib/frontend/widgets/link_text.dart | 6 +- lib/frontend/widgets/message_bubble.dart | 23 +- .../widgets/rich_message_controller.dart | 237 ++++++++++++++++++ test/message_format_test.dart | 177 +++++++++++++ 11 files changed, 1028 insertions(+), 36 deletions(-) create mode 100644 lib/core/utils/text_format.dart create mode 100644 lib/frontend/widgets/formatted_message_text.dart create mode 100644 lib/frontend/widgets/rich_message_controller.dart create mode 100644 test/message_format_test.dart diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 74f433b..0d60137 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -11,6 +11,7 @@ import '../../core/cache/message_session_cache.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; +import '../../core/utils/text_format.dart'; import '../api.dart'; import 'folders.dart'; import 'messages.dart' show ContactCache, CachedMessage; @@ -40,6 +41,7 @@ class CachedChat { final int? lastMsgTime; final String? lastMsgText; final String? lastMsgTextOneLine; + final String? lastMsgElements; final int? lastMsgSenderId; final String? lastMsgStatus; final int unreadCount; @@ -63,6 +65,7 @@ class CachedChat { this.lastMsgId, this.lastMsgTime, this.lastMsgText, + this.lastMsgElements, this.lastMsgSenderId, this.lastMsgStatus, required this.unreadCount, @@ -82,6 +85,16 @@ class CachedChat { bool get isOfficial => options.contains('OFFICIAL'); + List get lastMsgFormatRanges { + final raw = lastMsgElements; + if (raw == null || raw.isEmpty) return const []; + try { + return parseFormatElements(jsonDecode(raw)); + } catch (_) { + return const []; + } + } + bool get lastMsgReadByOthers { final t = lastMsgTime; if (t == null) return false; @@ -108,6 +121,7 @@ class CachedChat { lastMsgId: row['last_msg_id'] as int?, lastMsgTime: row['last_msg_time'] as int?, lastMsgText: row['last_msg_text'] as String?, + lastMsgElements: row['last_msg_elements'] as String?, lastMsgSenderId: row['last_msg_sender'] as int?, lastMsgStatus: row['last_msg_status'] as String?, unreadCount: row['unread_count'] as int, @@ -146,6 +160,7 @@ class CachedChat { 'last_msg_id': lastMsgId, 'last_msg_time': lastMsgTime, 'last_msg_text': lastMsgText, + 'last_msg_elements': lastMsgElements, 'last_msg_sender': lastMsgSenderId, 'last_msg_status': lastMsgStatus, 'unread_count': unreadCount, @@ -290,6 +305,14 @@ class ChatsModule { return attachPreviewLabel(msg['attaches']); } + static String? messagePreviewElements(Map msg) { + final text = msg['text']; + if (text is! String || text.isEmpty) return null; + final elements = msg['elements']; + if (elements is List && elements.isNotEmpty) return jsonEncode(elements); + return null; + } + static final _messageEventsController = StreamController.broadcast(); static Stream get messageEvents => @@ -365,6 +388,7 @@ class ChatsModule { required int time, required String text, required String status, + List>? elements, }) async { final rows = await AppDatabase.loadChat(accountId, chatId); if (rows.isEmpty) return; @@ -375,6 +399,9 @@ class ChatsModule { if (time < existingTime && existingId != thisId) return; row['last_msg_id'] = thisId; row['last_msg_text'] = text; + row['last_msg_elements'] = (elements != null && elements.isNotEmpty) + ? jsonEncode(elements) + : null; row['last_msg_time'] = time; row['last_event_time'] = time; row['last_msg_sender'] = accountId; @@ -618,6 +645,7 @@ class ChatsModule { } } newRow['last_msg_text'] = messagePreviewText(msg); + newRow['last_msg_elements'] = messagePreviewElements(msg); if (senderId != null) newRow['last_msg_sender'] = senderId; newRow['last_msg_status'] = 'sent'; } @@ -642,8 +670,10 @@ class ChatsModule { final newRow = Map.from(chatRow); if (latest.isNotEmpty) { final m = latest.first; - String? previewText = m['text']?.toString(); - if (previewText == null || previewText.isEmpty) { + final rawText = m['text']?.toString(); + String? previewText = rawText; + String? elementsJson; + if (rawText == null || rawText.isEmpty) { final payloadRaw = m['payload']; if (payloadRaw is String && payloadRaw.isNotEmpty) { try { @@ -653,15 +683,28 @@ class ChatsModule { } } catch (_) {} } + } else { + final payloadRaw = m['payload']; + if (payloadRaw is String && payloadRaw.isNotEmpty) { + try { + final payload = jsonDecode(payloadRaw); + if (payload is Map) { + final els = payload['elements']; + if (els is List && els.isNotEmpty) elementsJson = jsonEncode(els); + } + } catch (_) {} + } } newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? ''); newRow['last_msg_text'] = previewText ?? m['text']; + newRow['last_msg_elements'] = elementsJson; newRow['last_msg_time'] = m['time']; newRow['last_msg_sender'] = m['sender_id']; newRow['last_msg_status'] = m['status']; } else { newRow['last_msg_id'] = null; newRow['last_msg_text'] = lastMsgPlaceholder; + newRow['last_msg_elements'] = null; newRow['last_msg_sender'] = null; newRow['last_msg_status'] = null; } @@ -937,6 +980,7 @@ class ChatsModule { if (a.lastMsgId != b.lastMsgId) return false; if (a.lastMsgTime != b.lastMsgTime) return false; if (a.lastMsgText != b.lastMsgText) return false; + if (a.lastMsgElements != b.lastMsgElements) return false; if (a.lastMsgSenderId != b.lastMsgSenderId) return false; if (a.unreadCount != b.unreadCount) return false; if (a.lastEventTime != b.lastEventTime) return false; @@ -1100,12 +1144,14 @@ class ChatsModule { int? lastMsgId; int? lastMsgTime; String? lastMsgText; + String? lastMsgElements; int? lastMsgSenderId; if (lastMsg is Map) { lastMsgId = lastMsg['id'] as int?; lastMsgTime = lastMsg['time'] as int?; lastMsgText = messagePreviewText(lastMsg); + lastMsgElements = messagePreviewElements(lastMsg); lastMsgSenderId = lastMsg['sender'] as int?; } @@ -1169,6 +1215,7 @@ class ChatsModule { lastMsgId: lastMsgId, lastMsgTime: lastMsgTime, lastMsgText: lastMsgText, + lastMsgElements: lastMsgElements, lastMsgSenderId: lastMsgSenderId, unreadCount: (chat['newMessages'] as int?) ?? 0, lastEventTime: (chat['lastEventTime'] as int?) ?? 0, diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index b7e18ab..d23ff47 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -8,6 +8,7 @@ import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; +import '../../core/utils/text_format.dart'; import '../../models/attachment.dart'; import 'chats.dart' show ChatsModule; @@ -502,6 +503,8 @@ class CachedMessage { ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload); + List get formatRanges => parseFormatElements(payload?['elements']); + static List _decodeRows(List> rows) => rows.map(CachedMessage.fromDbRow).toList(); @@ -733,11 +736,12 @@ class MessagesModule { bool notify = true, int? scheduledTime, int? replyToMessageId, + List> elements = const [], }) async { final message = { 'text': text, 'cid': DateTime.now().millisecondsSinceEpoch * -1, - 'elements': [], + 'elements': elements, 'attaches': [], }; if (replyToMessageId != null) { diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index a8ea7a6..3efa47b 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -190,7 +190,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 15, + version: 16, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -247,6 +247,11 @@ class AppDatabase { if (oldVersion < 15) { await _addColumnIfMissing(db, 'messages', 'edit_history', 'TEXT'); } + if (oldVersion < 16) { + await _addColumnIfMissing( + db, 'chats_cache', 'last_msg_elements', 'TEXT', + ); + } }, ); } @@ -333,6 +338,7 @@ class AppDatabase { last_msg_id INTEGER, last_msg_time INTEGER, last_msg_text TEXT, + last_msg_elements TEXT, last_msg_sender INTEGER, last_msg_status TEXT, unread_count INTEGER NOT NULL DEFAULT 0, diff --git a/lib/core/utils/text_format.dart b/lib/core/utils/text_format.dart new file mode 100644 index 0000000..a321c45 --- /dev/null +++ b/lib/core/utils/text_format.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; + +enum TextFormat { + strong, + emphasized, + underline, + strikethrough, + monospaced, + quote, + link, +} + +const Map _formatToServer = { + TextFormat.strong: 'STRONG', + TextFormat.emphasized: 'EMPHASIZED', + TextFormat.underline: 'UNDERLINE', + TextFormat.strikethrough: 'STRIKETHROUGH', + TextFormat.monospaced: 'MONOSPACED', + TextFormat.quote: 'QUOTE', + TextFormat.link: 'LINK', +}; + +final Map _serverToFormat = { + for (final e in _formatToServer.entries) e.value: e.key, +}; + +String textFormatToServer(TextFormat format) => _formatToServer[format]!; + +TextFormat? textFormatFromServer(String? raw) => + raw == null ? null : _serverToFormat[raw]; + +class FormatRange { + final TextFormat format; + final int start; + final int length; + final Map? attributes; + + const FormatRange({ + required this.format, + required this.start, + required this.length, + this.attributes, + }); + + int get end => start + length; + + String? get url { + final value = attributes?['url']; + return value is String ? value : null; + } + + Map toServer() => { + 'type': textFormatToServer(format), + 'from': start, + 'length': length, + if (attributes != null) 'attributes': attributes, + }; +} + +List parseFormatElements(dynamic raw) { + if (raw is! List) return const []; + final result = []; + for (final item in raw) { + if (item is! Map) continue; + final format = textFormatFromServer(item['type']?.toString()); + if (format == null) continue; + final from = _asInt(item['from']); + final length = _asInt(item['length']); + if (length <= 0) continue; + final attrsRaw = item['attributes']; + final attributes = attrsRaw is Map + ? Map.from(attrsRaw) + : null; + result.add( + FormatRange( + format: format, + start: from, + length: length, + attributes: attributes, + ), + ); + } + return result; +} + +List> serializeFormatElements( + Iterable ranges, +) => [for (final range in ranges) range.toServer()]; + +int _asInt(dynamic value) { + if (value is int) return value; + if (value is String) return int.tryParse(value) ?? 0; + return 0; +} + +class FormatSegment { + final int start; + final int end; + final Set formats; + final String? url; + + const FormatSegment({ + required this.start, + required this.end, + required this.formats, + this.url, + }); +} + +List segmentizeFormats(String text, List ranges) { + if (text.isEmpty) return const []; + final length = text.length; + final clamped = []; + for (final range in ranges) { + final start = range.start.clamp(0, length); + final end = range.end.clamp(0, length); + if (end <= start) continue; + clamped.add( + FormatRange( + format: range.format, + start: start, + length: end - start, + attributes: range.attributes, + ), + ); + } + if (clamped.isEmpty) { + return [FormatSegment(start: 0, end: length, formats: const {})]; + } + + final boundaries = {0, length}; + for (final range in clamped) { + boundaries.add(range.start); + boundaries.add(range.end); + } + final points = boundaries.toList()..sort(); + + final segments = []; + for (var i = 0; i < points.length - 1; i++) { + final start = points[i]; + final end = points[i + 1]; + if (end <= start) continue; + final formats = {}; + String? url; + for (final range in clamped) { + if (range.start <= start && range.end >= end) { + formats.add(range.format); + if (range.format == TextFormat.link) url ??= range.url; + } + } + segments.add( + FormatSegment(start: start, end: end, formats: formats, url: url), + ); + } + return segments; +} + +TextStyle applyTextFormats( + TextStyle base, + Set formats, { + Color? linkColor, + Color? quoteColor, + Color? monoColor, +}) { + if (formats.isEmpty) return base; + var style = base; + final decorations = []; + + if (formats.contains(TextFormat.strong)) { + style = style.merge(const TextStyle(fontWeight: FontWeight.w700)); + } + if (formats.contains(TextFormat.emphasized) || + formats.contains(TextFormat.quote)) { + style = style.merge(const TextStyle(fontStyle: FontStyle.italic)); + } + if (formats.contains(TextFormat.monospaced)) { + style = style.merge( + TextStyle( + fontFamily: 'monospace', + color: monoColor ?? style.color, + ), + ); + } + if (formats.contains(TextFormat.quote) && quoteColor != null) { + style = style.merge(TextStyle(color: quoteColor)); + } + if (formats.contains(TextFormat.underline)) { + decorations.add(TextDecoration.underline); + } + if (formats.contains(TextFormat.strikethrough)) { + decorations.add(TextDecoration.lineThrough); + } + if (formats.contains(TextFormat.link)) { + decorations.add(TextDecoration.underline); + if (linkColor != null) style = style.merge(TextStyle(color: linkColor)); + } + + if (decorations.isNotEmpty) { + style = style.merge( + TextStyle(decoration: TextDecoration.combine(decorations)), + ); + } + return style; +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 480391c..c3148dd 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -17,7 +17,9 @@ import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/swipe_route.dart'; import '../../widgets/sliding_pill_nav.dart'; +import '../../widgets/formatted_message_text.dart'; import '../../../core/utils/format.dart'; +import '../../../core/utils/text_format.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -1540,6 +1542,9 @@ class _ChatListScreenState extends State draft: _draftFor(chat.id), ownStatus: _ownStatusFor(chat, isPlaceholder), ownRead: chat.lastMsgReadByOthers, + messageRanges: isPlaceholder + ? const [] + : chat.lastMsgFormatRanges, ), ); } else { @@ -1550,14 +1555,30 @@ class _ChatListScreenState extends State : null; String fullMsg = ""; + List messageRanges = const []; if (isPlaceholder) { fullMsg = 'зайдите в чат для подгрузки'; } else { + var prefixLen = 0; if (sender?.isNotEmpty == true && chat.id != 0) { - fullMsg += "$sender: "; + final prefix = "$sender: "; + fullMsg += prefix; + prefixLen = prefix.length; } if (chat.lastMsgText?.isNotEmpty == true) { fullMsg += chat.lastMsgText ?? ""; + final ranges = chat.lastMsgFormatRanges; + messageRanges = prefixLen == 0 + ? ranges + : [ + for (final r in ranges) + FormatRange( + format: r.format, + start: r.start + prefixLen, + length: r.length, + attributes: r.attributes, + ), + ]; } } @@ -1580,6 +1601,7 @@ class _ChatListScreenState extends State draft: chat.id == 0 ? null : _draftFor(chat.id), ownStatus: _ownStatusFor(chat, isPlaceholder), ownRead: chat.lastMsgReadByOthers, + messageRanges: messageRanges, ), ); } @@ -2216,6 +2238,7 @@ class _ChatListScreenState extends State String? draft, String? ownStatus, bool ownRead = false, + List messageRanges = const [], }) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedChats.contains(id); @@ -2427,19 +2450,35 @@ class _ChatListScreenState extends State maxLines: 1, overflow: TextOverflow.ellipsis, ) - : Text( - message, - style: TextStyle( - color: cs.outline, - fontSize: 14, - fontWeight: FontWeight.w400, - fontStyle: messageItalic - ? FontStyle.italic - : FontStyle.normal, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + : Builder( + builder: (_) { + final previewStyle = TextStyle( + color: cs.outline, + fontSize: 14, + fontWeight: FontWeight.w400, + fontStyle: messageItalic + ? FontStyle.italic + : FontStyle.normal, + height: 1.2, + ); + if (messageRanges.isEmpty) { + return Text( + message, + style: previewStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + return Text.rich( + FormattedMessageText.buildInlineSpan( + message, + messageRanges, + previewStyle, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }, ), ), ), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 0a8fd78..1eefae8 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -53,6 +53,8 @@ import '../../../models/sticker.dart'; import '../../commands/command_registry.dart'; import '../../commands/slash_command.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/rich_message_controller.dart'; +import '../../../core/utils/text_format.dart'; import '../../widgets/command_suggestions_panel.dart'; import '../../widgets/online_dot.dart'; import '../../widgets/connection_status.dart'; @@ -201,7 +203,7 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State with TickerProviderStateMixin, WidgetsBindingObserver { - final TextEditingController _messageController = TextEditingController(); + final RichMessageController _messageController = RichMessageController(); final FocusNode _messageFocusNode = FocusNode(); double _keyboardReserve = 0; bool _keyboardWasOpen = false; @@ -1531,7 +1533,8 @@ class _ChatScreenState extends State Future _startEditMessage(CachedMessage message) async { final cs = Theme.of(context).colorScheme; - final controller = TextEditingController(text: message.text ?? ''); + final controller = RichMessageController(text: message.text ?? '') + ..setFormatRanges(message.formatRanges); final saved = await showModalBottomSheet( context: context, @@ -1567,6 +1570,8 @@ class _ChatScreenState extends State minLines: 1, maxLines: 6, style: TextStyle(color: cs.onSurface), + contextMenuBuilder: (ctx, state) => + _formatContextMenu(controller, ctx, state), decoration: InputDecoration( hintText: 'Текст сообщения', filled: true, @@ -1592,14 +1597,24 @@ class _ChatScreenState extends State return; } - final newText = controller.text.trim(); + final rawText = controller.text; + final newText = rawText.trim(); + final elements = _trimmedElements(controller, rawText, newText); controller.dispose(); - if (newText == (message.text ?? '')) return; + + final oldElements = serializeFormatElements( + message.formatRanges.where((r) => composerFormats.contains(r.format)), + ); + if (newText == (message.text ?? '') && + _sameElements(elements, oldElements)) { + return; + } final ok = await messagesModule.editMessage( widget.chatId, message.id, text: newText, + elements: elements, ); if (!mounted) return; if (!ok) { @@ -1626,7 +1641,7 @@ class _ChatScreenState extends State text: newText.isEmpty ? null : newText, time: old.time, status: 'EDITED', - payload: old.payload, + payload: {...?old.payload, 'elements': elements}, attachments: old.attachments, isControl: old.isControl, editHistory: newHistory, @@ -2634,8 +2649,98 @@ class _ChatScreenState extends State _syncOtherReadTime(); } + static String _formatLabel(TextFormat format) { + switch (format) { + case TextFormat.strong: + return 'Жирный'; + case TextFormat.emphasized: + return 'Курсив'; + case TextFormat.underline: + return 'Подчёркнутый'; + case TextFormat.strikethrough: + return 'Зачёркнутый'; + case TextFormat.monospaced: + return 'Моноширинный'; + case TextFormat.quote: + return 'Цитата'; + case TextFormat.link: + return 'Ссылка'; + } + } + + Widget _formatContextMenu( + RichMessageController controller, + BuildContext context, + EditableTextState editableState, + ) { + final selection = controller.selection; + final buttonItems = []; + if (selection.isValid && !selection.isCollapsed) { + for (final format in composerFormats) { + final active = controller.isFormatActive(format); + buttonItems.add( + ContextMenuButtonItem( + label: '${active ? '✓ ' : ''}${_formatLabel(format)}', + onPressed: () { + controller.toggleFormat(format); + editableState.hideToolbar(); + }, + ), + ); + } + } + buttonItems.addAll(editableState.contextMenuButtonItems); + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableState.contextMenuAnchors, + buttonItems: buttonItems, + ); + } + + static bool _sameElements( + List> a, + List> b, + ) { + if (a.length != b.length) return false; + String canon(List> els) { + final copy = [...els]..sort((x, y) { + final t = (x['type'] as String).compareTo(y['type'] as String); + return t != 0 ? t : (x['from'] as int).compareTo(y['from'] as int); + }); + return copy + .map((e) => '${e['type']}:${e['from']}:${e['length']}') + .join(','); + } + + return canon(a) == canon(b); + } + + List> _trimmedElements( + RichMessageController controller, + String rawText, + String text, + ) { + final raw = controller.elementsForSend(); + if (raw.isEmpty) return const []; + final leading = rawText.length - rawText.trimLeft().length; + final result = >[]; + for (final element in raw) { + var from = (element['from'] as int) - leading; + var length = element['length'] as int; + if (from < 0) { + length += from; + from = 0; + } + if (from >= text.length || length <= 0) continue; + if (from + length > text.length) length = text.length - from; + if (length <= 0) continue; + result.add({...element, 'from': from, 'length': length}); + } + return result; + } + Future _sendMessage() async { - final text = _messageController.text.trim(); + final rawText = _messageController.text; + final text = rawText.trim(); if (text.isEmpty || _myId == 0) return; if (AppCommands.current.value && text.startsWith('/')) { @@ -2679,6 +2784,15 @@ class _ChatScreenState extends State } _replyTo.value = null; + final elements = _trimmedElements(_messageController, rawText, text); + final Map? composedPayload = + (replyPayload == null && elements.isEmpty) + ? null + : { + ...?replyPayload, + if (elements.isNotEmpty) 'elements': elements, + }; + final composed = CachedMessage( id: tempId, accountId: _myId, @@ -2687,7 +2801,7 @@ class _ChatScreenState extends State text: text, time: now, status: online ? 'sending' : 'pending', - payload: replyPayload, + payload: composedPayload, ); _hasText.value = false; @@ -2706,6 +2820,7 @@ class _ChatScreenState extends State time: now, text: text, status: composed.status ?? 'sending', + elements: elements, )); // Instant tactile "whoosh" the moment the message leaves the composer, @@ -2723,6 +2838,7 @@ class _ChatScreenState extends State widget.chatId, text, replyToMessageId: replyId, + elements: elements, ); final index = _messages.indexWhere((m) => m.id == tempId); @@ -2735,7 +2851,7 @@ class _ChatScreenState extends State text: text, time: now, status: 'sent', - payload: replyPayload, + payload: composedPayload, ); _messages[index] = sent; _bumpMessages(); @@ -2747,6 +2863,7 @@ class _ChatScreenState extends State time: now, text: text, status: 'sent', + elements: elements, )); } @@ -2770,7 +2887,7 @@ class _ChatScreenState extends State text: text, time: now, status: 'pending', - payload: replyPayload, + payload: composedPayload, ); _messages[index] = queued; _bumpMessages(); @@ -2782,6 +2899,7 @@ class _ChatScreenState extends State time: now, text: text, status: 'pending', + elements: elements, )); } } @@ -4588,6 +4706,12 @@ class _ChatScreenState extends State maxLines: null, keyboardType: TextInputType.multiline, textAlignVertical: TextAlignVertical.center, + contextMenuBuilder: (ctx, state) => + _formatContextMenu( + _messageController, + ctx, + state, + ), decoration: InputDecoration( hintText: 'Message', hintStyle: TextStyle( diff --git a/lib/frontend/widgets/formatted_message_text.dart b/lib/frontend/widgets/formatted_message_text.dart new file mode 100644 index 0000000..849f6ef --- /dev/null +++ b/lib/frontend/widgets/formatted_message_text.dart @@ -0,0 +1,143 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../core/utils/link_opener.dart'; +import '../../core/utils/text_format.dart'; +import 'link_text.dart'; + +class FormattedMessageText extends StatefulWidget { + final String text; + final List ranges; + final TextStyle style; + final TextAlign textAlign; + + const FormattedMessageText({ + super.key, + required this.text, + required this.ranges, + required this.style, + this.textAlign = TextAlign.start, + }); + + static bool isFormatted(String? text, List ranges) => + text != null && + text.isNotEmpty && + (ranges.isNotEmpty || LinkText.hasLinks(text)); + + static TextSpan buildInlineSpan( + String text, + List ranges, + TextStyle style, + ) { + final quoteColor = style.color?.withValues(alpha: 0.85); + final segments = segmentizeFormats(text, ranges); + return TextSpan( + style: style, + children: [ + for (final segment in segments) + TextSpan( + text: text.substring(segment.start, segment.end), + style: applyTextFormats( + style, + segment.formats, + quoteColor: quoteColor, + ), + ), + ], + ); + } + + @override + State createState() => _FormattedMessageTextState(); +} + +class _FormattedMessageTextState extends State { + final List _recognizers = []; + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + List _withAutoLinks() { + final ranges = List.from(widget.ranges); + final hasExplicitLink = ranges.any((r) => r.format == TextFormat.link); + if (hasExplicitLink) return ranges; + for (final match in linkPattern.allMatches(widget.text)) { + final raw = match.group(0)!; + final target = raw.startsWith('www.') ? 'https://$raw' : raw; + ranges.add( + FormatRange( + format: TextFormat.link, + start: match.start, + length: match.end - match.start, + attributes: {'url': target}, + ), + ); + } + return ranges; + } + + @override + Widget build(BuildContext context) { + _disposeRecognizers(); + final segments = segmentizeFormats(widget.text, _withAutoLinks()); + final baseColor = widget.style.color ?? Theme.of(context).colorScheme.onSurface; + final barColor = baseColor.withValues(alpha: 0.4); + final quoteColor = baseColor.withValues(alpha: 0.85); + + final spans = []; + var prevQuote = false; + for (final segment in segments) { + final isQuote = segment.formats.contains(TextFormat.quote); + if (isQuote && !prevQuote) { + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Container( + width: 3, + height: (widget.style.fontSize ?? 16) * 1.15, + margin: const EdgeInsets.only(right: 6, left: 1), + decoration: BoxDecoration( + color: barColor, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ); + } + prevQuote = isQuote; + + final style = applyTextFormats( + widget.style, + segment.formats, + quoteColor: quoteColor, + ); + final content = widget.text.substring(segment.start, segment.end); + 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), + ); + } else { + spans.add(TextSpan(text: content, style: style)); + } + } + + return Text.rich( + TextSpan(style: widget.style, children: spans), + textAlign: widget.textAlign, + ); + } +} diff --git a/lib/frontend/widgets/link_text.dart b/lib/frontend/widgets/link_text.dart index 3c51c89..f0bbff3 100644 --- a/lib/frontend/widgets/link_text.dart +++ b/lib/frontend/widgets/link_text.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../../core/utils/link_opener.dart'; -final RegExp _urlPattern = RegExp( +final RegExp linkPattern = RegExp( r'(https?://[^\s<>]+|www\.[^\s<>]+)', caseSensitive: false, ); @@ -15,7 +15,7 @@ class LinkText extends StatefulWidget { const LinkText({super.key, required this.text, required this.style}); static bool hasLinks(String? text) => - text != null && _urlPattern.hasMatch(text); + text != null && linkPattern.hasMatch(text); @override State createState() => _LinkTextState(); @@ -41,7 +41,7 @@ class _LinkTextState extends State { final spans = []; var cursor = 0; - for (final match in _urlPattern.allMatches(widget.text)) { + for (final match in linkPattern.allMatches(widget.text)) { if (match.start > cursor) { spans.add(TextSpan(text: widget.text.substring(cursor, match.start))); } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 4735b97..2a52a90 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -25,7 +25,7 @@ import '../../core/utils/link_opener.dart'; import '../../core/utils/webview_support.dart'; import '../../core/config/app_link_preview.dart'; import 'custom_notification.dart'; -import 'link_text.dart'; +import 'formatted_message_text.dart'; import 'sticker_image.dart'; import '../../models/attachment.dart'; import 'poll_view.dart'; @@ -935,10 +935,15 @@ class MessageBubble extends StatelessWidget { final hasReactions = reactionChips.isNotEmpty; final textStyle = TextStyle(color: ctx.text, fontSize: 16, height: 1.3); + final ranges = message.formatRanges; final textWidget = isForwarded ? _buildForwardedInlineText(ctx, forwarded) - : (LinkText.hasLinks(message.text) - ? LinkText(text: message.text!, style: textStyle) + : (FormattedMessageText.isFormatted(message.text, ranges) + ? FormattedMessageText( + text: message.text!, + ranges: ranges, + style: textStyle, + ) : Text(message.text ?? '', style: textStyle)); final metaWidget = Text( @@ -1385,8 +1390,9 @@ class MessageBubble extends StatelessWidget { if (hasText) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 4), - child: LinkText( + child: FormattedMessageText( text: message.text!, + ranges: message.formatRanges, style: TextStyle( color: ctx.text, fontSize: 16, @@ -1822,8 +1828,13 @@ class MessageBubble extends StatelessWidget { Widget _buildCaption(_BubbleCtx ctx) { final style = TextStyle(color: ctx.text, fontSize: 16, height: 1.3); - if (LinkText.hasLinks(message.text)) { - return LinkText(text: message.text!, style: style); + final ranges = message.formatRanges; + if (FormattedMessageText.isFormatted(message.text, ranges)) { + return FormattedMessageText( + text: message.text!, + ranges: ranges, + style: style, + ); } return Text(message.text ?? '', style: style); } diff --git a/lib/frontend/widgets/rich_message_controller.dart b/lib/frontend/widgets/rich_message_controller.dart new file mode 100644 index 0000000..f4045e5 --- /dev/null +++ b/lib/frontend/widgets/rich_message_controller.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; + +import '../../core/utils/text_format.dart'; + +const List composerFormats = [ + TextFormat.strong, + TextFormat.emphasized, + TextFormat.underline, + TextFormat.strikethrough, + TextFormat.quote, +]; + +class _Interval { + int start; + int end; + _Interval(this.start, this.end); +} + +class RichMessageController extends TextEditingController { + final Map> _intervals = {}; + + RichMessageController({super.text}); + + @override + set value(TextEditingValue newValue) { + final oldText = value.text; + final newText = newValue.text; + if (oldText != newText) { + _remap(oldText, newText); + } + super.value = newValue; + } + + bool get hasFormatting => + _intervals.values.any((list) => list.isNotEmpty); + + void clearFormatting() { + if (_intervals.isEmpty) return; + _intervals.clear(); + notifyListeners(); + } + + void setFormatRanges(Iterable ranges) { + _intervals.clear(); + for (final range in ranges) { + if (!composerFormats.contains(range.format)) continue; + _intervals.putIfAbsent(range.format, () => []).add( + _Interval(range.start, range.end), + ); + } + for (final list in _intervals.values) { + _normalize(list); + } + notifyListeners(); + } + + List> elementsForSend() { + final ranges = []; + _intervals.forEach((format, list) { + for (final interval in list) { + ranges.add( + FormatRange( + format: format, + start: interval.start, + length: interval.end - interval.start, + ), + ); + } + }); + return serializeFormatElements(ranges); + } + + bool isFormatActive(TextFormat format) { + final selection = value.selection; + if (!selection.isValid || selection.isCollapsed) return false; + return _isCovered(_intervals[format], selection.start, selection.end); + } + + void toggleFormat(TextFormat format) { + final selection = value.selection; + if (!selection.isValid || selection.isCollapsed) return; + final start = selection.start; + final end = selection.end; + final list = _intervals.putIfAbsent(format, () => []); + if (_isCovered(list, start, end)) { + _subtract(list, start, end); + } else { + _add(list, start, end); + } + if (list.isEmpty) _intervals.remove(format); + notifyListeners(); + } + + void _remap(String oldText, String newText) { + if (_intervals.isEmpty) return; + final oldLen = oldText.length; + final newLen = newText.length; + + var prefix = 0; + final maxPrefix = oldLen < newLen ? oldLen : newLen; + while (prefix < maxPrefix && oldText[prefix] == newText[prefix]) { + prefix++; + } + var suffix = 0; + while (suffix < maxPrefix - prefix && + oldText[oldLen - 1 - suffix] == newText[newLen - 1 - suffix]) { + suffix++; + } + + final changeStart = prefix; + final oldChangeEnd = oldLen - suffix; + final delta = newLen - oldLen; + + int mapStart(int offset) { + if (offset < changeStart) return offset; + if (offset >= oldChangeEnd) return offset + delta; + return changeStart; + } + + int mapEnd(int offset) { + if (offset <= changeStart) return offset; + if (offset >= oldChangeEnd) return offset + delta; + return changeStart; + } + + final empty = []; + _intervals.forEach((format, list) { + for (final interval in list) { + interval.start = mapStart(interval.start); + interval.end = mapEnd(interval.end); + } + list.removeWhere((interval) => interval.end <= interval.start); + _normalize(list); + if (list.isEmpty) empty.add(format); + }); + for (final format in empty) { + _intervals.remove(format); + } + } + + static bool _isCovered(List<_Interval>? list, int start, int end) { + if (list == null || list.isEmpty) return false; + var cursor = start; + final sorted = [...list]..sort((a, b) => a.start.compareTo(b.start)); + for (final interval in sorted) { + if (interval.start > cursor) return false; + if (interval.end > cursor) cursor = interval.end; + if (cursor >= end) return true; + } + return cursor >= end; + } + + static void _add(List<_Interval> list, int start, int end) { + list.add(_Interval(start, end)); + _normalize(list); + } + + static void _subtract(List<_Interval> list, int start, int end) { + final result = <_Interval>[]; + for (final interval in list) { + if (interval.end <= start || interval.start >= end) { + result.add(interval); + continue; + } + if (interval.start < start) { + result.add(_Interval(interval.start, start)); + } + if (interval.end > end) { + result.add(_Interval(end, interval.end)); + } + } + list + ..clear() + ..addAll(result); + _normalize(list); + } + + static void _normalize(List<_Interval> list) { + if (list.length < 2) return; + list.sort((a, b) => a.start.compareTo(b.start)); + final merged = <_Interval>[list.first]; + for (var i = 1; i < list.length; i++) { + final current = list[i]; + final last = merged.last; + if (current.start <= last.end) { + if (current.end > last.end) last.end = current.end; + } else { + merged.add(current); + } + } + list + ..clear() + ..addAll(merged); + } + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + final baseStyle = style ?? const TextStyle(); + final content = text; + if (!hasFormatting || content.isEmpty) { + return TextSpan(style: baseStyle, text: content); + } + + final ranges = []; + _intervals.forEach((format, list) { + for (final interval in list) { + ranges.add( + FormatRange( + format: format, + start: interval.start, + length: interval.end - interval.start, + ), + ); + } + }); + + final baseColor = baseStyle.color; + final quoteColor = baseColor?.withValues(alpha: 0.85); + final segments = segmentizeFormats(content, ranges); + final spans = [ + for (final segment in segments) + TextSpan( + text: content.substring(segment.start, segment.end), + style: applyTextFormats( + baseStyle, + segment.formats, + quoteColor: quoteColor, + ), + ), + ]; + return TextSpan(style: baseStyle, children: spans); + } +} diff --git a/test/message_format_test.dart b/test/message_format_test.dart new file mode 100644 index 0000000..1ccd748 --- /dev/null +++ b/test/message_format_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/utils/text_format.dart'; +import 'package:komet/frontend/widgets/formatted_message_text.dart'; +import 'package:komet/frontend/widgets/rich_message_controller.dart'; + +void main() { + group('parse + segmentize', () { + test('missing from defaults to 0', () { + final ranges = parseFormatElements([ + {'type': 'STRONG', 'length': 2}, + ]); + expect(ranges.single.start, 0); + expect(ranges.single.length, 2); + expect(ranges.single.format, TextFormat.strong); + }); + + test('overlapping ranges split into segments with merged formats', () { + const text = 'Hi, how are you?'; + final ranges = parseFormatElements([ + {'type': 'STRIKETHROUGH', 'from': 0, 'length': 2}, + {'type': 'QUOTE', 'from': 0, 'length': 16}, + {'type': 'EMPHASIZED', 'from': 4, 'length': 3}, + {'type': 'UNDERLINE', 'from': 4, 'length': 3}, + {'type': 'EMPHASIZED', 'from': 7, 'length': 5}, + {'type': 'STRONG', 'from': 12, 'length': 4}, + {'type': 'EMPHASIZED', 'from': 12, 'length': 4}, + ]); + final segments = segmentizeFormats(text, ranges); + expect(segments.first.start, 0); + expect(segments.last.end, 16); + for (var i = 0; i + 1 < segments.length; i++) { + expect(segments[i].end, segments[i + 1].start); + } + final seg = segments.firstWhere((s) => s.start == 4); + expect(seg.formats, containsAll([ + TextFormat.quote, + TextFormat.emphasized, + TextFormat.underline, + ])); + final last = segments.firstWhere((s) => s.start == 12); + expect(last.formats, containsAll([ + TextFormat.strong, + TextFormat.emphasized, + TextFormat.quote, + ])); + }); + }); + + group('RichMessageController', () { + test('toggle emits element and toggle again removes it', () { + final c = RichMessageController(text: 'Hi druk'); + c.selection = const TextSelection(baseOffset: 3, extentOffset: 7); + c.toggleFormat(TextFormat.underline); + final els = c.elementsForSend(); + expect(els, [ + {'type': 'UNDERLINE', 'from': 3, 'length': 4}, + ]); + c.selection = const TextSelection(baseOffset: 3, extentOffset: 7); + c.toggleFormat(TextFormat.underline); + expect(c.elementsForSend(), isEmpty); + }); + + test('range shifts when text inserted before it', () { + final c = RichMessageController(text: 'bold'); + c.selection = const TextSelection(baseOffset: 0, extentOffset: 4); + c.toggleFormat(TextFormat.strong); + c.value = c.value.copyWith( + text: 'XXbold', + selection: const TextSelection.collapsed(offset: 2), + ); + expect(c.elementsForSend(), [ + {'type': 'STRONG', 'from': 2, 'length': 4}, + ]); + }); + + test('range grows when typing inside it', () { + final c = RichMessageController(text: 'Hello'); + c.selection = const TextSelection(baseOffset: 0, extentOffset: 5); + c.toggleFormat(TextFormat.strong); + c.value = c.value.copyWith( + text: 'HelXlo', + selection: const TextSelection.collapsed(offset: 4), + ); + expect(c.elementsForSend(), [ + {'type': 'STRONG', 'from': 0, 'length': 6}, + ]); + }); + + test('range drops when its text is deleted', () { + final c = RichMessageController(text: 'alo'); + c.selection = const TextSelection(baseOffset: 0, extentOffset: 3); + c.toggleFormat(TextFormat.strikethrough); + c.value = c.value.copyWith( + text: '', + selection: const TextSelection.collapsed(offset: 0), + ); + expect(c.elementsForSend(), isEmpty); + }); + + test('overlapping toggles of different types coexist', () { + final c = RichMessageController(text: 'Hi, how are you?'); + c.selection = const TextSelection(baseOffset: 4, extentOffset: 7); + c.toggleFormat(TextFormat.emphasized); + c.toggleFormat(TextFormat.underline); + final els = c.elementsForSend(); + expect(els.length, 2); + expect(els, containsAll([ + {'type': 'EMPHASIZED', 'from': 4, 'length': 3}, + {'type': 'UNDERLINE', 'from': 4, 'length': 3}, + ])); + }); + + test('adjacent same-type toggles merge', () { + final c = RichMessageController(text: 'abcdef'); + c.selection = const TextSelection(baseOffset: 0, extentOffset: 3); + c.toggleFormat(TextFormat.strong); + c.selection = const TextSelection(baseOffset: 3, extentOffset: 6); + c.toggleFormat(TextFormat.strong); + expect(c.elementsForSend(), [ + {'type': 'STRONG', 'from': 0, 'length': 6}, + ]); + }); + + test('setFormatRanges loads composer formats and drops others', () { + final c = RichMessageController(text: 'Hi druk'); + c.setFormatRanges(parseFormatElements([ + {'type': 'UNDERLINE', 'from': 3, 'length': 4}, + {'type': 'MONOSPACED', 'from': 0, 'length': 2}, + {'type': 'LINK', 'from': 0, 'length': 2, 'attributes': {'url': 'x'}}, + ])); + expect(c.elementsForSend(), [ + {'type': 'UNDERLINE', 'from': 3, 'length': 4}, + ]); + }); + + test('buildInlineSpan reproduces preview text with prefix shift', () { + const preview = 'Alice: Hi druk'; + final ranges = parseFormatElements([ + {'type': 'STRONG', 'from': 3, 'length': 4}, + ]).map((r) => FormatRange( + format: r.format, + start: r.start + 'Alice: '.length, + length: r.length, + )); + final span = FormattedMessageText.buildInlineSpan( + preview, + ranges.toList(), + const TextStyle(), + ); + expect(span.toPlainText(), preview); + }); + + testWidgets('buildTextSpan reproduces text exactly', (tester) async { + final c = RichMessageController(text: 'Hi, how are you?'); + c.selection = const TextSelection(baseOffset: 4, extentOffset: 7); + c.toggleFormat(TextFormat.strong); + c.toggleFormat(TextFormat.emphasized); + late TextSpan span; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + span = c.buildTextSpan( + context: context, + style: const TextStyle(), + withComposing: false, + ); + return const SizedBox(); + }, + ), + ), + ); + expect(span.toPlainText(), 'Hi, how are you?'); + }); + }); +}