форматирование сообщений

This commit is contained in:
Jganenokk
2026-07-03 13:45:49 +07:00
parent 28b445fb8f
commit 48451c5d6c
11 changed files with 1028 additions and 36 deletions
+49 -2
View File
@@ -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<FormatRange> 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<MessageEvent>.broadcast();
static Stream<MessageEvent> get messageEvents =>
@@ -365,6 +388,7 @@ class ChatsModule {
required int time,
required String text,
required String status,
List<Map<String, dynamic>>? 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<String, dynamic>.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,
+5 -1
View File
@@ -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<FormatRange> get formatRanges => parseFormatElements(payload?['elements']);
static List<CachedMessage> _decodeRows(List<Map<String, dynamic>> rows) =>
rows.map(CachedMessage.fromDbRow).toList();
@@ -733,11 +736,12 @@ class MessagesModule {
bool notify = true,
int? scheduledTime,
int? replyToMessageId,
List<Map<String, dynamic>> elements = const [],
}) async {
final message = <String, dynamic>{
'text': text,
'cid': DateTime.now().millisecondsSinceEpoch * -1,
'elements': [],
'elements': elements,
'attaches': [],
};
if (replyToMessageId != null) {
+7 -1
View File
@@ -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,
+204
View File
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
enum TextFormat {
strong,
emphasized,
underline,
strikethrough,
monospaced,
quote,
link,
}
const Map<TextFormat, String> _formatToServer = {
TextFormat.strong: 'STRONG',
TextFormat.emphasized: 'EMPHASIZED',
TextFormat.underline: 'UNDERLINE',
TextFormat.strikethrough: 'STRIKETHROUGH',
TextFormat.monospaced: 'MONOSPACED',
TextFormat.quote: 'QUOTE',
TextFormat.link: 'LINK',
};
final Map<String, TextFormat> _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<String, dynamic>? 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<String, dynamic> toServer() => {
'type': textFormatToServer(format),
'from': start,
'length': length,
if (attributes != null) 'attributes': attributes,
};
}
List<FormatRange> parseFormatElements(dynamic raw) {
if (raw is! List) return const [];
final result = <FormatRange>[];
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<String, dynamic>.from(attrsRaw)
: null;
result.add(
FormatRange(
format: format,
start: from,
length: length,
attributes: attributes,
),
);
}
return result;
}
List<Map<String, dynamic>> serializeFormatElements(
Iterable<FormatRange> 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<TextFormat> formats;
final String? url;
const FormatSegment({
required this.start,
required this.end,
required this.formats,
this.url,
});
}
List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
if (text.isEmpty) return const [];
final length = text.length;
final clamped = <FormatRange>[];
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 = <int>{0, length};
for (final range in clamped) {
boundaries.add(range.start);
boundaries.add(range.end);
}
final points = boundaries.toList()..sort();
final segments = <FormatSegment>[];
for (var i = 0; i < points.length - 1; i++) {
final start = points[i];
final end = points[i + 1];
if (end <= start) continue;
final formats = <TextFormat>{};
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<TextFormat> formats, {
Color? linkColor,
Color? quoteColor,
Color? monoColor,
}) {
if (formats.isEmpty) return base;
var style = base;
final decorations = <TextDecoration>[];
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;
}
@@ -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<ChatListScreen>
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<ChatListScreen>
: null;
String fullMsg = "";
List<FormatRange> 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<ChatListScreen>
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<ChatListScreen>
String? draft,
String? ownStatus,
bool ownRead = false,
List<FormatRange> messageRanges = const [],
}) {
final cs = Theme.of(context).colorScheme;
final isSelected = _selectedChats.contains(id);
@@ -2427,19 +2450,35 @@ class _ChatListScreenState extends State<ChatListScreen>
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,
);
},
),
),
),
+133 -9
View File
@@ -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<ChatScreen>
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<ChatScreen>
Future<void> _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<bool>(
context: context,
@@ -1567,6 +1570,8 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
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<ChatScreen>
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<ChatScreen>
_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 = <ContextMenuButtonItem>[];
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<Map<String, dynamic>> a,
List<Map<String, dynamic>> b,
) {
if (a.length != b.length) return false;
String canon(List<Map<String, dynamic>> els) {
final copy = [...els]..sort((x, y) {
final t = (x['type'] as String).compareTo(y['type'] as String);
return t != 0 ? t : (x['from'] as int).compareTo(y['from'] as int);
});
return copy
.map((e) => '${e['type']}:${e['from']}:${e['length']}')
.join(',');
}
return canon(a) == canon(b);
}
List<Map<String, dynamic>> _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 = <Map<String, dynamic>>[];
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<void> _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<ChatScreen>
}
_replyTo.value = null;
final elements = _trimmedElements(_messageController, rawText, text);
final Map<String, dynamic>? 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<ChatScreen>
text: text,
time: now,
status: online ? 'sending' : 'pending',
payload: replyPayload,
payload: composedPayload,
);
_hasText.value = false;
@@ -2706,6 +2820,7 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
widget.chatId,
text,
replyToMessageId: replyId,
elements: elements,
);
final index = _messages.indexWhere((m) => m.id == tempId);
@@ -2735,7 +2851,7 @@ class _ChatScreenState extends State<ChatScreen>
text: text,
time: now,
status: 'sent',
payload: replyPayload,
payload: composedPayload,
);
_messages[index] = sent;
_bumpMessages();
@@ -2747,6 +2863,7 @@ class _ChatScreenState extends State<ChatScreen>
time: now,
text: text,
status: 'sent',
elements: elements,
));
}
@@ -2770,7 +2887,7 @@ class _ChatScreenState extends State<ChatScreen>
text: text,
time: now,
status: 'pending',
payload: replyPayload,
payload: composedPayload,
);
_messages[index] = queued;
_bumpMessages();
@@ -2782,6 +2899,7 @@ class _ChatScreenState extends State<ChatScreen>
time: now,
text: text,
status: 'pending',
elements: elements,
));
}
}
@@ -4588,6 +4706,12 @@ class _ChatScreenState extends State<ChatScreen>
maxLines: null,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.center,
contextMenuBuilder: (ctx, state) =>
_formatContextMenu(
_messageController,
ctx,
state,
),
decoration: InputDecoration(
hintText: 'Message',
hintStyle: TextStyle(
@@ -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<FormatRange> 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<FormatRange> ranges) =>
text != null &&
text.isNotEmpty &&
(ranges.isNotEmpty || LinkText.hasLinks(text));
static TextSpan buildInlineSpan(
String text,
List<FormatRange> 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<FormattedMessageText> createState() => _FormattedMessageTextState();
}
class _FormattedMessageTextState extends State<FormattedMessageText> {
final List<TapGestureRecognizer> _recognizers = [];
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
List<FormatRange> _withAutoLinks() {
final ranges = List<FormatRange>.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 = <InlineSpan>[];
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,
);
}
}
+3 -3
View File
@@ -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<LinkText> createState() => _LinkTextState();
@@ -41,7 +41,7 @@ class _LinkTextState extends State<LinkText> {
final spans = <InlineSpan>[];
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)));
}
+17 -6
View File
@@ -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);
}
@@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
import '../../core/utils/text_format.dart';
const List<TextFormat> 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<TextFormat, List<_Interval>> _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<FormatRange> 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<Map<String, dynamic>> elementsForSend() {
final ranges = <FormatRange>[];
_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 = <TextFormat>[];
_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 = <FormatRange>[];
_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 = <InlineSpan>[
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);
}
}