Files
Qlyra/lib/frontend/widgets/formatted_message_text.dart
T

344 lines
9.7 KiB
Dart

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<FormatRange> ranges;
final TextStyle style;
final TextAlign textAlign;
final TextEntityMode entityMode;
final int? maxLines;
final TextOverflow? overflow;
const FormattedMessageText({
super.key,
required this.text,
required this.ranges,
required this.style,
this.textAlign = TextAlign.start,
this.entityMode = TextEntityMode.menu,
this.maxLines,
this.overflow,
});
static bool isFormatted(String? text, List<FormatRange> ranges) =>
text != null &&
text.isNotEmpty &&
(ranges.isNotEmpty || LinkText.hasLinks(text) || hasTextEntities(text));
static TextSpan buildInlineSpan(
String text,
List<FormatRange> ranges,
TextStyle style, {
Color? mentionColor,
}) {
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,
mentionColor: mentionColor,
),
),
],
);
}
@override
State<FormattedMessageText> createState() => _FormattedMessageTextState();
}
class _FormattedMessageTextState extends State<FormattedMessageText> {
final List<GestureRecognizer> _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 = linkTarget(raw);
ranges.add(
FormatRange(
format: TextFormat.link,
start: match.start,
length: match.end - match.start,
attributes: {'url': target},
),
);
}
return ranges;
}
void _openMention(int userId) {
unawaited(
openContactDialogProfile(
context,
contactId: userId,
name: ContactCache.get(userId) ?? 'User #$userId',
avatarUrl: ContactCache.getAvatar(userId),
),
);
}
T _track<T extends GestureRecognizer>(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<TextSpanRange> _claimedRanges(List<FormatRange> ranges) => [
for (final range in ranges)
if (range.format == TextFormat.link ||
range.format == TextFormat.userMention)
(start: range.start, end: range.end),
];
TextEntity? _entityAt(List<TextEntity> 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<TextEntity> entities,
) {
final points = <int>{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 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 = <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,
mentionColor: mentionColor,
);
final content = widget.text.substring(segment.start, segment.end);
if (segment.animojiUrl != null) {
final fontSize = widget.style.fontSize ?? 16;
final box = fontSize * 1.5;
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: SizedBox(
width: box,
height: box,
child: Stack(
alignment: Alignment.center,
children: [
Text(
content,
style: widget.style.copyWith(fontSize: fontSize * 1.15),
),
LottieImage(
lottieUrl: segment.animojiUrl,
size: box,
memCacheWidth: 120,
shimmer: false,
eager: true,
),
],
),
),
),
);
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!;
spans.add(
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),
),
);
}
}
return Text.rich(
TextSpan(style: widget.style, children: spans),
textAlign: widget.textAlign,
maxLines: widget.maxLines,
overflow: widget.overflow ?? TextOverflow.clip,
);
}
}