119 lines
4.5 KiB
Dart
119 lines
4.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
|
|
|
import '../../../../utils/url_opener.dart';
|
|
import '../data/talk_markdown.dart';
|
|
|
|
/// Renders a Talk chat-message body as Markdown, matching the subset the
|
|
/// official Nextcloud Talk clients render.
|
|
///
|
|
/// Supported (the intentional "Rahmen", kept in sync with Talk's GFM-based
|
|
/// renderer): bold, italic, strikethrough, inline code, links (explicit and
|
|
/// bare autolinks), headings, ordered/unordered/task lists, fenced code
|
|
/// blocks, block quotes, horizontal rules and tables.
|
|
///
|
|
/// Deliberately unsupported, mirroring Talk's restrictions: raw HTML is shown
|
|
/// literally (never interpreted) and images are not fetched/rendered — only
|
|
/// their alt text (or URL) is shown, so a message can never pull a remote
|
|
/// resource on display.
|
|
class MessageMarkdown extends StatelessWidget {
|
|
final String data;
|
|
final TextStyle? style;
|
|
|
|
const MessageMarkdown({required this.data, this.style, super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final base =
|
|
style ??
|
|
theme.textTheme.bodyMedium ??
|
|
DefaultTextStyle.of(context).style;
|
|
final baseSize = base.fontSize ?? 14;
|
|
final muted = (base.color ?? theme.colorScheme.onSurface).withValues(
|
|
alpha: 0.55,
|
|
);
|
|
final codeBackground = theme.colorScheme.surfaceContainerHighest.withValues(
|
|
alpha: 0.6,
|
|
);
|
|
final codeStyle = base.copyWith(
|
|
fontFamily: 'monospace',
|
|
fontFamilyFallback: const ['monospace'],
|
|
fontSize: baseSize * 0.92,
|
|
);
|
|
|
|
final styleSheet = MarkdownStyleSheet.fromTheme(theme).copyWith(
|
|
p: base,
|
|
a: base.copyWith(
|
|
color: Colors.blue,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
em: base.copyWith(fontStyle: FontStyle.italic),
|
|
strong: base.copyWith(fontWeight: FontWeight.bold),
|
|
del: base.copyWith(decoration: TextDecoration.lineThrough),
|
|
// Talk levels headings down so they stay readable inside a chat bubble;
|
|
// we cap them at modest multiples of the body size instead of the theme's
|
|
// display sizes.
|
|
h1: base.copyWith(fontSize: baseSize * 1.5, fontWeight: FontWeight.bold),
|
|
h2: base.copyWith(fontSize: baseSize * 1.4, fontWeight: FontWeight.bold),
|
|
h3: base.copyWith(fontSize: baseSize * 1.3, fontWeight: FontWeight.bold),
|
|
h4: base.copyWith(fontSize: baseSize * 1.15, fontWeight: FontWeight.bold),
|
|
h5: base.copyWith(fontSize: baseSize * 1.05, fontWeight: FontWeight.bold),
|
|
h6: base.copyWith(fontWeight: FontWeight.bold),
|
|
code: codeStyle.copyWith(backgroundColor: codeBackground),
|
|
codeblockPadding: const EdgeInsets.all(8),
|
|
codeblockDecoration: BoxDecoration(
|
|
color: codeBackground,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
blockquote: base.copyWith(color: muted),
|
|
blockquotePadding: const EdgeInsets.only(left: 12, top: 2, bottom: 2),
|
|
blockquoteDecoration: BoxDecoration(
|
|
border: Border(left: BorderSide(color: muted, width: 4)),
|
|
),
|
|
listBullet: base,
|
|
checkbox: base,
|
|
horizontalRuleDecoration: BoxDecoration(
|
|
border: Border(top: BorderSide(color: muted)),
|
|
),
|
|
tableHead: base.copyWith(fontWeight: FontWeight.bold),
|
|
tableBody: base,
|
|
tableBorder: TableBorder.all(color: muted, width: 0.8),
|
|
tableCellsPadding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 3,
|
|
),
|
|
// Keep multi-block messages compact; the default spacing is too airy for
|
|
// a chat bubble.
|
|
blockSpacing: baseSize * 0.5,
|
|
);
|
|
|
|
return MarkdownBody(
|
|
data: data,
|
|
styleSheet: styleSheet,
|
|
extensionSet: talkMarkdownExtensionSet,
|
|
// Talk turns a single newline into a line break (`breaks: true`);
|
|
// standard Markdown would collapse it into a space.
|
|
softLineBreak: true,
|
|
onTapLink: (text, href, title) {
|
|
if (href != null && href.isNotEmpty) unawaited(UrlOpener.openUrl(href));
|
|
},
|
|
// No remote fetch: render the alt text (or URL) instead of the image.
|
|
imageBuilder: (uri, title, alt) => Text(
|
|
(alt != null && alt.isNotEmpty) ? alt : uri.toString(),
|
|
style: base,
|
|
),
|
|
checkboxBuilder: (checked) => Padding(
|
|
padding: const EdgeInsets.only(right: 4),
|
|
child: Icon(
|
|
checked ? Icons.check_box_outlined : Icons.check_box_outline_blank,
|
|
size: baseSize + 2,
|
|
color: base.color,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|