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, ), ), ); } }